コミットを比較

..
16 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 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
182個のファイルの変更、9193行の追加、5921行の削除
+162 -24
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- For arrays, never put whitespace or a line break immediately before `]`. - For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default. - Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent - If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces. elements 4 spaces deeper than the statement's base indentation.
### Ruby delimiter and wrapping rules ### Ruby delimiter and wrapping rules
@@ -140,6 +140,32 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。 99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)` を - Ruby の method chain や call argument を折り返す際、call-site の `)` を
block close のやうに独立させない。 block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
並べない。
- RSpec の `describe`、`context`、`it` 等の description が長い場合は、意味を
保ったまま一行へ収まる文言へ短縮する。
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
検討する。
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。
Bad: Bad:
@@ -191,6 +217,63 @@ end
Bad: Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb
it(
'returns 409 when stale changes '
'do not conflict'
) do
```
Good:
```rb
it 'returns mergeable 409 for stale non-conflicting changes' do
```
Bad:
```rb ```rb
records.each { records.each {
do_work(_1) } do_work(_1) }
@@ -225,20 +308,37 @@ records.each {
formatting as the local reference shape: component and callback bodies use formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary 2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi- braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs stage ternaries use explicit parentheses, and TypeScript / TSX indentation
of 8 spaces are compressed to tabs. and alignment whitespace compress every complete run of 8 spaces to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
- A tab does not represent one indentation level. - A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs. - In TypeScript and TSX, first determine visible indentation using 2-space
- First determine visible indentation using 2-space block indentation and block indentation and 4-space continuation indentation, then compress every
4-space continuation indentation, then compress only complete leading runs complete run of 8 spaces used for indentation or column alignment to tabs.
of 8 spaces into tabs. - In TypeScript and TSX, 8-space compression is mandatory, not optional.
- Tabs are only for leading indentation, never for spaces after non-space - In TypeScript and TSX, this applies both to leading indentation and to
text. alignment whitespace after non-space text, such as aligned inline type
- Keep residual leading 2, 4, or 6 spaces after any tab compression. columns.
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
compression.
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
- In TypeScript and TSX, do not alter string literals, template-literal
contents, regular expressions, or user-facing text merely to apply tab
compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6 - Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1 spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 16 columns = 2 tabs. tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
- When TypeScript or TSX code already uses column alignment, apply the same
8-space compression rule to that alignment whitespace.
- Example:
```ts
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void }
```
- TypeScript and TSX imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations. - Keep runtime value imports and type imports in separate declarations.
@@ -474,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it. wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording. - Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - 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 test work. When the user asks for tests, keep working within the permitted
they pass or the remaining failure is clearly blocked. 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 ## Backend rules
@@ -529,6 +653,11 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content, - Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow. controls usable without horizontal page overflow.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm` と `md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
- For mobile horizontal scrollers, make the scroll direction and item sizing - For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes. explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and - In TypeScript and TSX, prefer direct comparison operators such as `===` and
@@ -593,10 +722,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line. beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing - The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style. parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses - The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
2 spaces per level, wrapped continuations use the repository's 4-space per level, wrapped continuations use the repository's 4-space continuation
continuation alignment, and complete leading runs of 8 spaces may be alignment, and every complete run of 8 spaces used for indentation or
compressed to tabs. alignment has been compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -609,9 +738,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Indent the block body 2 spaces deeper than the keyword and opening brace. - Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword. - Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`. - Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a - In TypeScript and TSX, convert every complete run of 8 spaces used for
tab character. indentation or alignment to a tab character.
- A leading tab is exactly equivalent to 8 leading spaces. - In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
appears at the beginning of a line or in alignment whitespace after
non-space text.
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
attributes use 4-space continuation indentation; after visible columns are
determined, compress every complete run of 8 spaces in the resulting
indentation or alignment to tabs.
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
columns as spaces alone; convert each complete run of 8 spaces to tabs and
keep only the residual 2, 4, or 6 spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing function declarations, classify the parameter list before placing the closing
`)`. `)`.
@@ -1254,9 +1392,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves 7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise. otherwise.
8. JSX closing parentheses keep the compact local style. 8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations 9. Block indentation uses 2 spaces per level, wrapped continuations use the
use the repository's 4-space continuation alignment, and complete leading repository's 4-space continuation alignment, and every complete run of 8
runs of 8 spaces may be compressed to tabs. spaces used for indentation or alignment has been compressed to tabs.
10. No line has trailing whitespace. 10. No line has trailing whitespace.
Preferred: Preferred:
+9
ファイルの表示
@@ -85,4 +85,13 @@ class ApplicationController < ActionController::API
base_errors: }, base_errors: },
status: status:
end end
def normalise_json value
return nil if value.nil?
return JSON.parse(value) if value.is_a?(String)
value
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end end
+7 -6
ファイルの表示
@@ -72,6 +72,7 @@ class MaterialsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file) file_sha256 = MaterialFileSha256.from_upload(file)
@@ -89,7 +90,7 @@ class MaterialsController < ApplicationController
begin begin
Material.transaction do Material.transaction do
tag = resolve_material_tag!(tag_name_raw) tag = resolve_material_tag!(locale, tag_name_raw)
material = Material.new(tag:, url:, material = Material.new(tag:, url:,
created_by_user: current_user, created_by_user: current_user,
updated_by_user: current_user) updated_by_user: current_user)
@@ -132,12 +133,14 @@ class MaterialsController < ApplicationController
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block return render_material_import_block(block) if block
locale = resolve_locale!
uploaded_blob = build_uploaded_material_blob!(file, file_sha256) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
begin begin
Material.transaction do Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag = resolve_material_tag!(tag_name_raw) tag = resolve_material_tag!(locale, tag_name_raw)
material.assign_attributes(tag:, url:, updated_by_user: current_user) material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob if uploaded_blob
material.file.attach(uploaded_blob) material.file.attach(uploaded_blob)
@@ -236,10 +239,8 @@ class MaterialsController < ApplicationController
nil nil
end end
def resolve_material_tag! tag_name_raw def resolve_material_tag! locale, tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end end
def material_index_needs_tag_name? filters def material_index_needs_tag_name? filters
+54 -27
ファイルの表示
@@ -15,30 +15,35 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1 limit = 1 if limit < 1
post_tag_max_sql = post_tag_max_sql =
PostTag PostExternalTag
.select('tag_id, MAX(created_at) AS max_created_at') .select('external_tag_id, MAX(created_at) AS max_created_at')
.group('tag_id') .group('external_tag_id')
.to_sql .to_sql
q = Tag.nico_tags q =
.joins(:tag_name) ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id') 'ON post_tag_max.external_tag_id = external_tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) .includes(linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag if linked_tag
linked_tag_ids = linked_tag_ids =
Tag Tag
.joins(:tag_name) .joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%") .where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id) .pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids) q = q.where(id: linked_nico_tag_ids)
end end
if link_status.in?(['linked', 'unlinked']) if link_status.in?(['linked', 'unlinked'])
exists_sql = exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \ 'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)' 'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
@@ -46,51 +51,58 @@ class NicoTagsController < ApplicationController
sort_sql = sort_sql =
case order[0] case order[0]
when 'name' when 'name'
'tag_names.name' 'external_tags.name'
when 'updated_at' when 'updated_at'
'post_tag_max.max_created_at' 'post_tag_max.max_created_at'
else else
"tags.#{ order[0] }" "external_tags.#{ order[0] }"
end end
tags = q.reselect('tags.*', tags = q.reselect('external_tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at')) Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) .order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
.limit(limit) .limit(limit)
.offset((page - 1) * limit) .offset((page - 1) * limit)
.to_a .to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge( external_tag_json(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at, recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: } }, count: }
end end
def show
tag = ExternalTag.find(params[:id])
render json: external_tag_json(tag)
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
id = params[:id].to_i id = params[:id].to_i
tag = Tag.find(id) tag = ExternalTag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = nil linked_tags = nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_no_deerjikist: false) with_tagme: false,
if linked_tags.any? { |t| t.nico? } with_no_deerjikist: false)
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
tag.save! tag.save!
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :update,
created_by_user: current_user)
end end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
@@ -102,6 +114,21 @@ class NicoTagsController < ApplicationController
private private
def external_tag_json tag
{ id: tag.id,
name: "#{ tag.platform }:#{ tag.name }",
category: 'nico',
post_count: tag.post_count,
created_at: tag.created_at,
updated_at: tag.created_at,
deprecated_at: nil,
aliases: [],
parents: [],
has_wiki: false,
material_id: nil,
has_deerjikists: false }
end
def render_nico_tag_form_record_invalid record def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag) if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message| render_validation_error fields: { tags: record.errors.full_messages.map { |message|
-49
ファイルの表示
@@ -1,49 +0,0 @@
class PostImportsController < ApplicationController
before_action :require_member!
def preview
rows = PostImportPreviewer.new.preview_rows(
rows: PostImportUrlListParser.parse(params[:source]))
render json: { rows: }
rescue ArgumentError => e
render_bad_request e.message
end
def validate
rows = normalised_import_rows allow_warning_fields: true
changed_row =
if params[:changed_row].to_s == 'all'
true
else
Integer(params[:changed_row], exception: false)
end
result =
PostImportPreviewer.new.preview_rows(rows:,
fetch_metadata: changed_row,
metadata_cache: { })
render json: { rows: result }
rescue ArgumentError => e
render_bad_request e.message
end
def create
result = PostImportRunner.new(actor: current_user,
rows: normalised_import_rows).run
render json: result, status: result[:created].positive? ? :created : :ok
rescue ArgumentError => e
render_bad_request e.message
end
private
def require_member!
return head :unauthorized unless current_user
return if current_user.gte_member?
head :forbidden
end
def normalised_import_rows allow_warning_fields: false
PostImportRowNormaliser.normalise!(params[:rows], allow_warning_fields:)
end
end
+84 -67
ファイルの表示
@@ -1,7 +1,10 @@
class PostVersionsController < ApplicationController class PostVersionsController < ApplicationController
def index def index
post_id = params[:post].presence post_id = params[:post].presence
tag_id = params[:tag].presence tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
return head :bad_request if tag_id && external_tag_id
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -10,12 +13,6 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit 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) q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN LEFT JOIN
post_versions prev post_versions prev
@@ -23,17 +20,23 @@ class PostVersionsController < ApplicationController
prev.post_id = post_versions.post_id prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1 AND prev.version_no = post_versions.version_no - 1
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .select('post_versions.*',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags', '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.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name) q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"), 'OR JSON_CONTAINS(prev.tags_json,' +
kw: "% #{ escaped } %") "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
elsif tag_id
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
end end
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -48,77 +51,91 @@ class PostVersionsController < ApplicationController
private private
def serialise_versions rows def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row| rows.map do |row|
cur_tags = split_tags(row.tags) cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = split_tags(row.attributes['prev_tags']) 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, version_no: row.version_no,
event_type: row.event_type, event_type: row.event_type,
title: { title: { current: row.title, prev: row.attributes['prev_title'] },
current: row.title, url: { current: row.url, prev: row.attributes['prev_url'] },
prev: row.attributes['prev_title'] thumbnail: { current: nil, prev: nil },
}, thumbnail_base: { current: row.thumbnail_base,
url: { prev: row.attributes['prev_thumbnail_base'] },
current: row.url, video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 prev: row.attributes['prev_original_created_from']&.iso8601 },
},
original_created_before: { original_created_before: {
current: row.original_created_before&.iso8601, current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601 prev: row.attributes['prev_original_created_before']&.iso8601 },
},
created_at: row.created_at.iso8601, created_at: row.created_at.iso8601,
created_by_user: created_by_user:
if row.created_by_user_id if row.created_by_user_id
{ { id: row.created_by_user_id,
id: row.created_by_user_id, name: users_by_id[row.created_by_user_id] }
name: users_by_id[row.created_by_user_id] end }
}
end
}
end 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| (cur_tags | prev_tags).map do |name|
type = type =
if cur_tags.include?(name) && prev_tags.include?(name) if cur_tags.include?(name) && prev_tags.include?(name)
'context' 'context'
elsif cur_tags.include?(name) elsif cur_tags.include?(name)
'added' 'added'
else else
'removed' 'removed'
end end
{ { name:, type: }
name:,
type:
}
end end
end end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end end
+278 -91
ファイルの表示
@@ -1,5 +1,6 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError class VideoMsParseError < ArgumentError
; ;
@@ -35,8 +36,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql = pt_max_sql =
PostTag PostVersion
.select('post_id, MAX(updated_at) AS max_updated_at') .select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id') .group('post_id')
.to_sql .to_sql
@@ -48,10 +49,9 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children, .preload(:external_tags, :uploaded_user, :parents, :children,
active_post_tags: [:sections, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -102,25 +102,96 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, post =
active_post_tags: [:sections, filtered_posts
{ tag: [:deerjikists, :materials, .preload(:uploaded_user, :parents, :children,
{ tag_name: :wiki_page }] }]) post_tags: [:sections, { tag: [:deerjikists, :materials,
.with_attached_thumbnail { tag_name: :wiki_page }] }])
.order('RAND()') .with_attached_thumbnail
.first .order('RAND()')
.first
return head :not_found unless post return head :not_found unless post
render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
end end
def metadata
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return render_bad_request('URL は必須です.') if params[:url].blank?
normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
return render_validation_error(fields: { url: ['URL が不正です.'] }) if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
if existing_post.present?
return render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { },
base_warnings: [],
existing_post_id: existing_post.id,
existing_post: compact_post(existing_post.id) }
end
metadata = PostMetadataFetcher.fetch(normal_url)
field_warnings = { }
field_warnings[:title] = ['タイトルを取得できませんでした.'] if metadata[:title].blank?
if metadata[:thumbnail_base].blank?
field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした.']
end
render json: {
url: normal_url,
title: metadata[:title],
thumbnail_base: metadata[:thumbnail_base],
tags: metadata[:tags],
display_tags: metadata[:display_tags],
original_created_from: metadata[:original_created_from],
original_created_before: metadata[:original_created_before],
duration: metadata[:duration],
video_ms: metadata[:video_ms],
field_warnings: field_warnings,
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
rescue ArgumentError => e
render_bad_request e.message
rescue Preview::UrlSafety::UnsafeUrl => e
render_validation_error fields: { url: [e.message] }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge
render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { url: ['自動取得に失敗しました.'] },
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
end
def show def show
post = post =
Post Post
.includes(:uploaded_user, :parents, :children, .includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
@@ -142,17 +213,43 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
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])
post.errors.add :url, :taken
return render_post_form_record_invalid post
end
post = PostCreator.new(actor: current_user, post = PostCreator.new(actor: current_user,
attributes: { title: params[:title], url: params[:url], attributes: post_create_attributes.merge(
thumbnail: params[:thumbnail], tags: params[:tags], preflight.slice(
original_created_from: params[:original_created_from], :url,
original_created_before: params[:original_created_before], :title,
parent_post_ids: parse_parent_post_ids, :thumbnail_base,
video_ms: params[:video_ms], :tags,
duration: params[:duration] }).create! :parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:direct_tag_specs,
:default_tag_specs,
:snapshot_tag_specs,
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!(locale)
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
@@ -161,6 +258,8 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] } render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] } render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue Post::RemoteThumbnailFetchFailed
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
rescue MiniMagick::Error rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] } render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e rescue ArgumentError => e
@@ -169,6 +268,30 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end end
def bulk
return head :unauthorized unless current_user
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(locale)
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
rescue ArgumentError => e
render_validation_error base: [e.message]
end
def viewed def viewed
return head :unauthorized unless current_user return head :unauthorized unless current_user
@@ -194,6 +317,7 @@ class PostsController < ApplicationController
base_version_no = parse_base_version_no base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no) return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
locale = Locale.find_by(code: params[:locale].presence) || Locale.nipponese
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
@@ -215,7 +339,8 @@ class PostsController < ApplicationController
base_snapshot = post_snapshot_from_version(base_version) base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post) current_snapshot = post_snapshot_from_record(post)
end end
incoming_snapshot = post_incoming_snapshot(title:, incoming_snapshot = post_incoming_snapshot(locale:,
title:,
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
@@ -244,7 +369,7 @@ class PostsController < ApplicationController
end end
end end
apply_post_snapshot!(post, snapshot_to_apply) apply_post_snapshot!(locale, post, snapshot_to_apply)
end end
return render json: conflict_json, status: :conflict if conflict_json return render json: conflict_json, status: :conflict if conflict_json
@@ -267,50 +392,6 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end 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 private
def filtered_posts def filtered_posts
@@ -356,8 +437,20 @@ class PostsController < ApplicationController
end end
end end
def tagged_post_ids_for(name) = def tagged_post_ids_for(name)
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) posts_by_internal_tags =
Post
.joins(tags: :tag_name)
.where(tag_names: { 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 def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t| desired_tags.each do |t|
@@ -385,13 +478,13 @@ class PostsController < ApplicationController
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.discard_by!(current_user) pt.destroy!
end end
end end
def build_tag_tree_for post def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? } post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag) tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
@@ -435,7 +528,10 @@ class PostsController < ApplicationController
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } internal_tags = root_ids.filter_map { |id| build_node.call(id, []) }
external_tags =
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
internal_tags + external_tags
end end
def sibling_posts_by_parent parent_post_ids def sibling_posts_by_parent parent_post_ids
@@ -463,6 +559,79 @@ class PostsController < ApplicationController
}.uniq }.uniq
end end
def post_create_attributes
{ title: params[:title],
url: params[:url],
thumbnail_base: params[:thumbnail_base],
tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }
end
def parse_bulk_posts_manifest
manifest = params[:posts]
raise ArgumentError, 'posts は必須です.' if manifest.blank?
raise ArgumentError, 'posts は JSON 文字列で指定してください.' unless manifest.is_a?(String)
posts = JSON.parse(manifest)
raise ArgumentError, 'posts は配列で指定してください.' unless posts.is_a?(Array)
raise ArgumentError, '投稿件数は 1 件以上必要です.' if posts.empty?
raise ArgumentError, '投稿件数が多すぎます.' if posts.length > 100
raise ArgumentError, 'posts 要素の形式が不正です.' unless posts.all? { _1.is_a?(Hash) }
posts
end
def parse_bulk_thumbnails post_count
thumbnails = { }
raw = params[:thumbnails]
return thumbnails if raw.blank?
raise ArgumentError, 'thumbnail key が不正です.' unless raw.respond_to?(:to_unsafe_h)
raw.to_unsafe_h.each do |key, value|
raise ArgumentError, 'thumbnail key が不正です.' unless key.to_s.match?(/\A\d+\z/)
index = Integer(key, 10)
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
raise ArgumentError, 'thumbnail index が重複しています.' if thumbnails.key?(index)
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
thumbnails[index] = value
end
thumbnails
end
def compact_post post_id
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: request.base_url)
end
def dry_run_json preflight
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:display_tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:field_warnings,
:base_warnings,
:existing_post_id,
:existing_post)
end
def sync_parent_posts! post, parent_post_ids def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id) if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.' post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
@@ -509,7 +678,10 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_version version def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort version.tags_json
.select { _1.key?('tag_id') }
.map { Post.tag_snapshot_literal(_1) }
.sort
end end
def post_snapshot_from_record post def post_snapshot_from_record post
@@ -524,9 +696,7 @@ class PostsController < ApplicationController
def editable_tag_names_from_post post def editable_tag_names_from_post post
post post
.post_tags .post_tags
.kept
.joins(tag: :tag_name) .joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil)) .merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name) .includes(:sections, tag: :tag_name)
.order('tag_names.name') .order('tag_names.name')
@@ -540,11 +710,14 @@ class PostsController < ApplicationController
end end
end end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: tag_names:, video_ms_param:, duration_param:, parent_post_ids:
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true, validate_original_created_values!(original_created_from, original_created_before)
with_sections: true) =>
{ tags:, sections: } Tag.normalise_tags!(locale, tag_names,
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:) video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -579,6 +752,23 @@ class PostsController < ApplicationController
value.to_s value.to_s
end end
def validate_original_created_values! original_created_from, original_created_before
candidate = Post.new(
url: 'https://example.invalid/original-created-validation',
original_created_from:,
original_created_before:)
candidate.valid?
fields = [:original_created_from, :original_created_before, :original_created_at]
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
return if relevant_errors.empty?
invalid_post = Post.new
relevant_errors.each { |error|
invalid_post.errors.add(error.attribute, error.message)
}
raise ActiveRecord::RecordInvalid, invalid_post
end
def section_literal section def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end end
@@ -662,7 +852,7 @@ class PostsController < ApplicationController
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end end
def apply_post_snapshot! post, snapshot def apply_post_snapshot! locale, post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
@@ -670,15 +860,12 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, Tag.normalise_tags!(locale, snapshot[:tag_names],
deny_deprecated: true, with_tagme: false,
with_sections: true) => deny_deprecated: true,
{ tags: editable_tags, sections: } with_sections: true) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
+3 -26
ファイルの表示
@@ -18,32 +18,9 @@ class PreviewController < ApplicationController
def thumbnail def thumbnail
return render_bad_request('URL は必須です.') if params[:url].blank? return render_bad_request('URL は必須です.') if params[:url].blank?
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url])) attachment =
image.auto_orient Post.resized_thumbnail_attachment(
image.resize '180x180>' StringIO.new(Preview::ThumbnailFetcher.fetch(params[:url])))
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
render_unprocessable_entity(e.message)
end
def image
return render_bad_request('URL は必須です.') if params[:url].blank?
attachment = Post.resized_thumbnail_attachment(
StringIO.new(Preview::ThumbnailFetcher.fetch_image_response(params[:url]).body))
attachment[:io].rewind
send_data attachment[:io].read, send_data attachment[:io].read,
type: attachment[:content_type], type: attachment[:content_type],
disposition: 'inline' disposition: 'inline'
-2
ファイルの表示
@@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+343 -104
ファイルの表示
@@ -34,49 +34,155 @@ class TagsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
q = tags =
if post_id.present? if post_id.present?
Tag.joins(:posts, :tag_name) Tag.joins(:posts, :tag_name).where(posts: { id: post_id })
else else
Tag.joins(:tag_name) Tag.joins(:tag_name)
end end
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name external_tags =
q = q.where(category:) if category if post_id.present?
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] ExternalTag.joins(:posts).where(posts: { id: post_id })
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] else
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] ExternalTag.all
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] end
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] if name
if deprecated_given tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil) external_tags =
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
"%#{ name }%")
end end
if category == 'nico'
tags = tags.none
elsif category
tags = tags.where(category:)
external_tags = external_tags.none
end
if post_count_between[0]
tags = tags.where('tags.post_count >= ?', post_count_between[0])
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
end
if post_count_between[1]
tags = tags.where('tags.post_count <= ?', post_count_between[1])
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
end
if created_between[0]
tags = tags.where('tags.created_at >= ?', created_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
end
if created_between[1]
tags = tags.where('tags.created_at <= ?', created_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
end
if updated_between[0]
tags = tags.where('tags.updated_at >= ?', updated_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
end
if updated_between[1]
tags = tags.where('tags.updated_at <= ?', updated_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
end
if deprecated_given
if deprecated
tags = tags.where.not(deprecated_at: nil)
external_tags = external_tags.none
else
tags = tags.where(deprecated_at: nil)
end
end
tag_sql = tags.select("'tag' AS source",
'tags.id',
'tag_names.name',
'tags.category',
'tags.post_count',
'tags.created_at',
'tags.updated_at',
'tags.deprecated_at').to_sql
external_tag_sql = external_tags.select(
"'external_tag' AS source",
'external_tags.id',
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
"'nico' AS category",
'external_tags.post_count',
'external_tags.created_at',
'external_tags.created_at AS updated_at',
'NULL AS deprecated_at').to_sql
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
sort_sql = sort_sql =
case order[0] if order[0] == 'category'
when 'name' 'CASE category ' +
'tag_names.name'
when 'category'
'CASE tags.category ' +
"WHEN 'deerjikist' THEN 0 " + "WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " + "WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " + "WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " + "WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " + "WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " + "WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END" "WHEN 'nico' THEN 6 " +
'END'
else else
"tags.#{ order[0] }" order[0]
end end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
render json: { tags: TagRepr.many(tags), count: q.size } connection = ApplicationRecord.connection
count = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
(#{ union_sql }) legacy_tags
SQL
rows = connection.select_all(<<~SQL).to_a
SELECT
source
, id
FROM
(#{ union_sql }) legacy_tags
ORDER BY
#{ sort_sql } #{ order[1] }
, id #{ order[1] }
, source #{ order[1] }
LIMIT
#{ limit }
OFFSET
#{ offset }
SQL
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
tags_by_id =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(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']))
else
external_tag_json(external_tags_by_id.fetch(row['id']))
end
}, count: }
end end
def with_depth def with_depth
@@ -100,13 +206,14 @@ class TagsController < ApplicationController
def autocomplete def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '') q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true) with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true) present_only = bool?(:present, default: true)
alias_rows = alias_rows =
TagName TagName
.where('name LIKE ?', "#{ q }%") .where('name LIKE ?', prefix)
.where.not(canonical_id: nil) .where.not(canonical_id: nil)
.pluck(:canonical_id, :name) .pluck(:canonical_id, :name)
@@ -118,54 +225,77 @@ class TagsController < ApplicationController
matched_alias_by_tag_name_id[canonical_id] ||= alias_name matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end end
base = Tag.joins(:tag_name) base =
.includes(:tag_name, :materials, tag_name: :wiki_page) Tag
.where(deprecated_at: nil) .joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
canonical_hit = canonical_hit = base.where('tag_names.name LIKE ?', prefix)
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
tags = internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
render json: tags.map { |tag| return render json: internal_rows unless with_nico
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
} external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { external_tag_json(_1) }
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name']] }
.first(20)
render json: rows
end end
def show def show
tag = Tag.joins(:tag_name) tag =
.includes(:tag_name, :materials, tag_name: :wiki_page) Tag
.find_by(id: params[:id]) .joins(:tag_name)
if tag .includes(:tag_name, :materials, tag_name: :wiki_page)
render json: TagRepr.base(tag) .find_by(id: params[:id])
else return render json: TagRepr.base(tag) if tag
head :not_found
end external_tag = ExternalTag.find_by(id: params[:id])
return render json: ExternalTagRepr.base(external_tag) if external_tag
head :not_found
end end
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag =
.includes(:tag_name, :materials, tag_name: :wiki_page) Tag
.find_by(tag_names: { name: }) .joins(:tag_name)
if tag .includes(:tag_name, :materials, tag_name: :wiki_page)
render json: TagRepr.base(tag) .find_by(tag_names: { name: })
else return render json: TagRepr.base(tag) if tag
head :not_found
end platform, external_name = name.split(':', 2)
return head :not_found unless external_name
external_tag = ExternalTag.find_by(platform:, name: external_name)
return head :not_found unless external_tag
render json: ExternalTagRepr.base(external_tag)
end end
def deerjikists def deerjikists
@@ -200,14 +330,45 @@ class TagsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless tag return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.deerjikists = [] tag.lock!
params[:_json].each.with_index do |item, i|
platform = item[:platform] requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
code = normalise_deerjikist_code(platform, item[:code]) row_indexes_by_key = rows_by_key(rows)
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
deerjikist.tag = tag current_deerjikists = locked_deerjikists.filter { |deerjikist|
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(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(conflicts, row_indexes_by_key, tag)
end
raise ActiveRecord::Rollback if performed? raise ActiveRecord::Rollback if performed?
end end
end end
@@ -245,17 +406,15 @@ class TagsController < ApplicationController
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
return unless validate_tag_rename(tag, name) locale = resolve_locale!
return unless validate_tag_rename(locale, tag, name)
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated) deprecated = bool?(:deprecated)
if tag.nico? && deprecated if category == 'nico'
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
@@ -277,7 +436,7 @@ class TagsController < ApplicationController
alias_names.delete(name) alias_names.delete(name)
update_aliases!(tag, alias_names) update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names) update_parent_tags!(locale, tag, parent_names)
tag.reload tag.reload
@@ -296,6 +455,8 @@ class TagsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
name = params[:name].presence name = params[:name].presence
category = params[:category].presence category = params[:category].presence
deprecated_given = params.key?(:deprecated) deprecated_given = params.key?(:deprecated)
@@ -303,13 +464,9 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated return unless validate_tag_rename(locale, tag, name)
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name) if category.present? && category == 'nico'
if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
@@ -516,11 +673,6 @@ class TagsController < ApplicationController
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil def record_tag_version! tag, event_type:, created_by_user:, 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:) TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed return unless name_changed
@@ -534,7 +686,7 @@ class TagsController < ApplicationController
created_by_user:) created_by_user:)
end end
def validate_tag_rename tag, name def validate_tag_rename locale, tag, name
return true if name.blank? || name == tag.name return true if name.blank? || name == tag.name
if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
@@ -542,9 +694,8 @@ class TagsController < ApplicationController
return false return false
end end
target_tag_name = TagName.with_discarded.find_by(name:) target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
return true if target_tag_name.nil? return true unless target_tag_name&.primary_flg
return true if target_tag_name.canonical_id?
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
false false
@@ -554,17 +705,14 @@ class TagsController < ApplicationController
return if name == tag.name return if name == tag.name
current_tag_name = tag.tag_name current_tag_name = tag.tag_name
target_tag_name = TagName.with_discarded.find_by(name:) target_tag_name = TagName.find_by(name:)
if target_tag_name.nil? if target_tag_name.nil?
current_tag_name.update!(name:) current_tag_name.update!(name:)
return return
end end
promote_tag_alias!( promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
end end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
@@ -574,11 +722,9 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil) promoted_tag_name.update!(canonical: nil)
TagName.with_discarded TagName.where(canonical_id: current_tag_name.id)
.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id) .where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name| .find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name) alias_tag_name.update!(canonical: promoted_tag_name)
@@ -609,7 +755,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag affected_tags << alias_tag_name.canonical&.tag
end end
@@ -624,7 +770,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
@@ -633,10 +779,10 @@ class TagsController < ApplicationController
end end
end end
def update_parent_tags! tag, parent_names def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, parent_tags = Tag.normalise_tags!(locale, parent_names,
with_no_deerjikist: false, with_tagme: false,
deny_nico: true) with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a old_parent_tags = tag.parents.to_a
@@ -653,6 +799,7 @@ class TagsController < ApplicationController
end end
def normalise_deerjikist_code platform, code def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@' return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }" url = "https://www.youtube.com/#{ code }"
@@ -669,6 +816,82 @@ class TagsController < ApplicationController
nil nil
end end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts 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_name)
.where(id: conflicts.map(&:tag_id).uniq)
.pluck('tags.id', 'tag_names.name')
.to_h
fields = { }
conflicts.each do |deerjikist|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
field = :"deerjikists.#{ index }.code"
fields[field] ||= []
fields[field] << message
end
end
render_validation_error fields:
end
def render_deerjikist_platform_invalid index
render_validation_error fields: {
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
}
end
def rows_by_key rows
rows.each_with_object({ }) do |row, result|
key = [row[:platform], row[:code]]
result[key] ||= []
result[key] << row[:index]
end
end
def render_deerjikist_form_record_invalid deerjikist, index def render_deerjikist_form_record_invalid deerjikist, index
fields = { } fields = { }
@@ -687,4 +910,20 @@ class TagsController < ApplicationController
render_validation_error fields: render_validation_error fields:
end end
def external_tag_json tag
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'deprecated_at' => nil,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'post_count' => tag.post_count,
'matched_alias' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
end end
+1 -1
ファイルの表示
@@ -94,7 +94,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title) tag_name = TagName.find_or_create_by!(name: title)
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
+20
ファイルの表示
@@ -0,0 +1,20 @@
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
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Language < ApplicationRecord
has_many :languages, class_name: 'Locale', foreign_key: :language_code
end
+24
ファイルの表示
@@ -0,0 +1,24 @@
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
def self.nipponese = Locale.find('ja')
private
def generate_tag_names!
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.name),
script_code:,
primary_flg: true,
# TODO: 公証実装したら書く.
# auto_generated: true,
canonical_id: nil)
end
end
end
+5 -19
ファイルの表示
@@ -1,24 +1,10 @@
class NicoTagRelation < ApplicationRecord class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, class_name: 'Tag' belongs_to :nico_tag,
belongs_to :tag, class_name: 'Tag' class_name: 'ExternalTag',
foreign_key: :nico_tag_id,
inverse_of: :nico_tag_relations
belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id
validates :nico_tag_id, presence: true validates :nico_tag_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
def nico_tag_must_be_nico
if nico_tag && nico_tag.category != 'nico'
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end
end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord class NicoTagVersion < ApplicationRecord
include VersionRecord include VersionRecord
belongs_to :tag belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true validates :name, presence: true
end end
+261 -26
ファイルの表示
@@ -1,7 +1,9 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'date' require 'date'
require 'mini_magick' require 'mini_magick'
require 'nokogiri'
require 'stringio' require 'stringio'
require 'timeout'
class RemoteThumbnailFetchFailed < StandardError; end class RemoteThumbnailFetchFailed < StandardError; end
@@ -11,44 +13,69 @@ class Post < ApplicationRecord
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE = ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze 'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
def self.resized_thumbnail_attachment(upload) MAX_SVG_DIMENSION = 4_096
MAX_SVG_PIXELS = 16_777_216
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) bytes = upload.read
image.resize '180x180^' blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
image.gravity 'Center' image = image_for_thumbnail_upload(bytes, content_type:)
image.extent '180x180' image.auto_orient
image.format 'jpg' image.resize '180x180'
image.format 'jpg'
image.to_blob
end
{ io: StringIO.new(image.to_blob), { io: StringIO.new(blob),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' } content_type: 'image/jpeg' }
rescue Timeout::Error
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
ensure ensure
upload.rewind upload.rewind
end end
def self.remote_thumbnail_attachment(raw_url)
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
resized_thumbnail_attachment(
StringIO.new(response.body),
content_type: response.content_type)
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge,
Timeout::Error,
MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message
end
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post has_many :tags, through: :post_tags
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) }, has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :active_post_tags, source: :tag through: :post_tags,
source: :tag
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games, has_many :gekanator_guessed_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :guessed_post_id, foreign_key: :guessed_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :guessed_post inverse_of: :guessed_post
has_many :gekanator_correct_games, has_many :gekanator_correct_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :correct_post_id, foreign_key: :correct_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :correct_post inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications, has_many :parent_post_implications,
@@ -65,6 +92,9 @@ class Post < ApplicationRecord
inverse_of: :parent_post inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post has_many :children, through: :child_post_implications, source: :post
has_many :post_external_tags, dependent: :destroy
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail has_one_attached :thumbnail
attribute :version_no, :integer, default: 1 attribute :version_no, :integer, default: 1
@@ -98,7 +128,6 @@ class Post < ApplicationRecord
def snapshot_tag_names def snapshot_tag_names
post_tags post_tags
.kept
.joins(tag: :tag_name) .joins(tag: :tag_name)
.includes(:sections, tag: :tag_name) .includes(:sections, tag: :tag_name)
.order('tag_names.name') .order('tag_names.name')
@@ -112,8 +141,46 @@ class Post < ApplicationRecord
end end
end end
def self.tag_snapshot_literal tag
sections = tag.fetch('sections', []).map do |sec|
begin_ms = sec.fetch('begin_ms')
end_ms = sec['end_ms']
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
"#{ tag.fetch('name') }#{ sections.join }"
end
def snapshot_tags_json
tag_snapshots =
post_tags
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map { |pt|
{ 'tag_id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
}
external_tag_snapshots =
post_external_tags.order(:external_tag_id).map {
{ 'external_tag_id' => _1.external_tag_id }
}
tag_snapshots + external_tag_snapshots
end
def self.section_literal section def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]" end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end end
def self.ms_to_time ms def self.ms_to_time ms
@@ -157,17 +224,7 @@ class Post < ApplicationRecord
end end
def attach_thumbnail_from_url! raw_url def attach_thumbnail_from_url! raw_url
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url) thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
thumbnail.attach(
self.class.resized_thumbnail_attachment(
StringIO.new(response.body)))
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge,
MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message
end end
private private
@@ -209,6 +266,179 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
end end
def self.image_for_thumbnail_upload(bytes, content_type: nil)
if svg_content_type?(content_type) || svg_document_bytes?(bytes)
return decode_svg_thumbnail(bytes)
end
raise MiniMagick::Error, 'サムネイル画像の形式が不正です.' unless raster_thumbnail_bytes?(bytes)
decode_raster_thumbnail(bytes)
end
def self.raster_thumbnail_bytes?(bytes)
raster_thumbnail_format(bytes).present?
end
def self.remote_thumbnail_image_bytes?(bytes, content_type: nil)
return true if svg_content_type?(content_type) || svg_document_bytes?(bytes)
raster_thumbnail_bytes?(bytes)
end
def self.svg_content_type?(content_type)
content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
end
def self.svg_document_bytes?(bytes)
document = Nokogiri::XML(
bytes,
nil,
nil,
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET)
document.root&.name == 'svg'
rescue Nokogiri::XML::SyntaxError
false
end
def self.decode_raster_thumbnail(bytes)
MiniMagick::Image.read(bytes)
end
def self.decode_svg_thumbnail(bytes)
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end
def self.raster_thumbnail_format(bytes)
binary = bytes.to_s.b
return 'jpeg' if binary.start_with?("\xFF\xD8\xFF".b)
return 'png' if binary.start_with?("\x89PNG\r\n\x1A\n".b)
return 'gif' if binary.start_with?('GIF87a'.b) || binary.start_with?('GIF89a'.b)
return 'webp' if binary.bytesize >= 12 &&
binary.start_with?('RIFF'.b) &&
binary.byteslice(8, 4) == 'WEBP'
nil
end
def self.sanitised_svg_bytes(bytes)
parse_options =
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET
document = Nokogiri::XML(
bytes,
nil,
nil,
parse_options)
root = document.root
raise MiniMagick::Error, 'SVG が不正です.' if root == nil || root.name != 'svg'
raise MiniMagick::Error, 'SVG が不正です.' if document.internal_subset != nil
raise MiniMagick::Error, 'SVG が不正です.' if svg_uses_disallowed_features?(document)
width, height = svg_dimensions(root)
raise MiniMagick::Error, 'SVG が大きすぎます.' if width == nil || height == nil
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
raise MiniMagick::Error, 'SVG が大きすぎます.'
end
document.to_xml
rescue Nokogiri::XML::SyntaxError
raise MiniMagick::Error, 'SVG が不正です.'
end
def self.svg_uses_disallowed_features?(document)
document.traverse.any? do |node|
next false unless node.element?
name = node.name.to_s.downcase
next true if name == 'script' || name == 'foreignobject'
next style_contains_disallowed_urls?(node.text.to_s) if name == 'style'
node.attribute_nodes.any? do |attribute|
attribute_name = attribute.name.to_s.downcase
attribute_value = attribute.value.to_s
attribute_name.start_with?('on') ||
external_svg_reference?(attribute_name, attribute_value)
end
end
end
def self.external_svg_reference?(attribute_name, attribute_value)
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)
false
end
def self.external_svg_url?(value)
stripped = value.to_s.strip
return false if stripped.blank? || stripped.start_with?('#')
true
end
def self.style_contains_disallowed_urls?(value)
text = value.to_s
text.match?(/@import/i) || svg_url_function_disallowed?(text)
end
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.present? && !(reference.start_with?('#'))
end
end
def self.svg_dimensions(root)
width = svg_length_to_pixels(root['width'])
height = svg_length_to_pixels(root['height'])
return [width, height] if width && height
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
return [nil, nil] unless view_box[3].finite? && view_box[3].positive?
[view_box[2], view_box[3]]
end
def self.svg_length_to_pixels(value)
return nil if value.blank?
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
return nil if matched == nil
pixels = Float(matched[1])
return nil unless pixels.finite? && pixels.positive?
pixels
rescue ArgumentError
nil
end
private_class_method :image_for_thumbnail_upload,
:svg_content_type?,
:decode_raster_thumbnail,
:decode_svg_thumbnail,
:raster_thumbnail_format,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:external_svg_reference?,
:external_svg_url?,
:style_contains_disallowed_urls?,
:svg_url_function_disallowed?,
:svg_dimensions,
:svg_length_to_pixels
def parse_original_created_value field def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast") raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field) value = public_send(field)
@@ -252,7 +482,12 @@ class Post < ApplicationRecord
match = match =
value.match( value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/) /
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
(?::(\d{2})(?:\.(\d+))?)?
(Z|[+-]\d{2}:?\d{2})?
\z
/x)
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
+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 class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post belongs_to :post
belongs_to :tag, counter_cache: :post_count belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection', has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id], foreign_key: [:post_id, :tag_id],
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { validates :post_id, uniqueness: { scope: :tag_id }
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
end end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post belongs_to :post
belongs_to :tag belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id], belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id], primary_key: [:post_id, :tag_id],
inverse_of: :sections, inverse_of: :sections,
optional: true optional: true
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Script < ApplicationRecord
;
end
+48 -57
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
@@ -28,15 +26,12 @@ class Tag < ApplicationRecord
end end
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :posts, through: :post_tags
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :reversed_nico_tag_relations, has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy class_name: 'NicoTagRelation',
foreign_key: :tag_id,
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -54,8 +49,8 @@ class Tag < ApplicationRecord
has_many :materials has_many :materials
has_many :tag_versions has_many :tag_versions
has_many :nico_tag_versions
has_many :tag_names
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
@@ -69,17 +64,13 @@ class Tag < ApplicationRecord
character: 'character', character: 'character',
general: 'general', general: 'general',
material: 'material', material: 'material',
nico: 'nico',
meta: 'meta' meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys } validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :nico_tag_name_must_start_with_nico validate :tag_name_mustnt_start_with_nico
validate :tag_name_must_be_canonical validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = { CATEGORY_PREFIXES = {
'general:' => :general, 'general:' => :general,
@@ -105,19 +96,20 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.no_deerjikist =
def self.video = find_or_create_by_tag_name!('動画', category: :meta) find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta)
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags! locale, tag_names,
with_no_deerjikist: true, with_tagme: true,
deny_nico: true, with_no_deerjikist: true,
deny_deprecated: false, deny_deprecated: false,
with_sections: false with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -143,9 +135,9 @@ class Tag < ApplicationRecord
raise SectionLiteralParseError.new(raw_name, raw_name) raise SectionLiteralParseError.new(raw_name, raw_name)
end end
name = TagName.canonicalise(name).first name = TagName.canonicalise(locale, name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated? if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name] raise DeprecatedTagNormalisationError, [tag.name]
end end
@@ -233,13 +225,22 @@ class Tag < ApplicationRecord
[left_end_ms, right_end_ms].max [left_end_ms, right_end_ms].max
end end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! locale, name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) language_code = locale.language_code
tn = tn.canonical if tn.canonical_id? name = name.to_s.strip
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| tn = TagName.find_or_create_by!(language_code:, name:) do
t.category = category _1.script_code = locale.script_code
_1.primary_flg = true
end end
tag = tn.tag
return tag if tag
tag = Tag.create!(tag_name: tn, category:)
tn.update!(tag:)
tag
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
retry retry
end end
@@ -259,11 +260,11 @@ class Tag < ApplicationRecord
TagVersioning.ensure_snapshot!(source_tag, created_by_user:) TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.kept.find_each do |source_pt| source_tag.post_tags.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
affected_post_ids << post_id affected_post_ids << post_id
source_pt.discard_by!(created_by_user) source_pt.destroy!
unless PostTag.kept.exists?(post_id:, tag: target_tag) unless PostTag.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
end end
end end
@@ -275,14 +276,12 @@ class Tag < ApplicationRecord
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard! source_tag.tag_names.update_all(tag_id: source_tag.id, primary_key: false,
updated_at: Time.current)
source_tag.destroy!
if source_tag.nico? source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
source_tag_name.discard! updated_at: Time.current)
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:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end end
@@ -293,13 +292,13 @@ class Tag < ApplicationRecord
end end
# 投稿件数を再集計 # 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
end end
target_tag.reload target_tag.reload
end end
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
@@ -309,11 +308,9 @@ class Tag < ApplicationRecord
private private
def nico_tag_name_must_start_with_nico def tag_name_mustnt_start_with_nico
n = name.to_s if name.to_s.downcase.start_with?('nico:')
if ((nico? && !(n.downcase.start_with?('nico:'))) || errors.add :name, 'タグの命名規則に反してゐます.'
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end
@@ -354,10 +351,4 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end end
+32 -22
ファイルの表示
@@ -1,45 +1,55 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
include MyDiscard belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag, optional: true
has_one :tag
has_one :wiki_page has_one :wiki_page
belongs_to :canonical, class_name: 'TagName', optional: true validates :name, presence: true,
has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id 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 :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_must_be_sanitised
def self.canonicalise names def primary? = primary_flg
def canonical = TagName.find_by(language:, tag:, primary_flg: true)
def aliases = TagName.where(language:, tag:, primary_flg: false)
def self.canonicalise locale, names
names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?)
return [] if names.blank? return [] if names.blank?
tns = TagName.includes(:canonical).where(name: names).index_by(&:name) tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
names.map { |name| tns[name]&.canonical&.name || name }.uniq names.map { |name|
if tns[name].primary_flg
name
else
TagName.find_by(language_code: locale.language_code,
tag_id: tns[name].tag_id,
primary_flg: true).name
end
}.uniq
end
def self.generate_name locale, tag, name
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end end
private private
def canonical_must_be_canonical
if canonical&.canonical_id?
errors.add :canonical, 'canonical は実体を示す必要があります.'
end
end
def alias_name_must_not_have_prefix def alias_name_must_not_have_prefix
if canonical_id? && name.to_s.include?(':') if !(primary?) && name.to_s.include?(':')
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
end end
end end
def canonical_must_not_be_present_with_tag_or_wiki_page def alias_must_not_have_wiki_page
if canonical_id? && (tag || wiki_page) if !(primary?) && wiki_page
errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.' errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end end
end end
+29 -21
ファイルの表示
@@ -10,35 +10,43 @@ class TagNameSanitisationRule < ApplicationRecord
validate :source_pattern_must_be_regexp validate :source_pattern_must_be_regexp
class << self class << self
def sanitise(name) = def sanitise(name)
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) } rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
end
def apply! def apply!
TagName.find_each do |tn| Language.find_each do |language|
name = sanitise(tn.name) TagName.where(language:).find_each do |tn|
next if name == tn.name name = sanitise(tn.name)
next if name == tn.name
TagName.transaction do TagName.transaction do
existing_tn = TagName.find_by(name:) existing_tn = TagName.find_by(language:, name:)
if existing_tn if existing_tn
existing_tn = existing_tn.canonical || existing_tn unless existing_tn.primary_flg
next if existing_tn.id == tn.id 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) existing_tag = existing_tn.tag
source_tag = Tag.find_by(tag_name_id: tn.id) source_tag = tn.tag
if existing_tag if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, 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 end
tn.discard!
next # TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end end
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end end
end end
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
+19
ファイルの表示
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module PostCompactRepr
module_function
def base post, host: nil
return nil if post.nil?
PostRepr
.common(post, host:)
.slice(
'id',
'title',
'url',
'thumbnail',
'thumbnail_base')
end
end
+80 -27
ファイルの表示
@@ -17,8 +17,13 @@ module PostRepr
module_function module_function
def base post, current_user = nil def base post, current_user = nil, host: nil
json = common(post) json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post) json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user) json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false json['viewed'] = current_user ? current_user.viewed?(post) : false
@@ -26,50 +31,98 @@ module PostRepr
end end
def detail post, current_user = nil, parent_posts: [], child_posts: [], def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: [] sibling_posts: { }, related: [], host: nil
base(post, current_user).merge( if host.present?
'parent_posts' => cards(parent_posts), base(post, current_user, host:).merge(
'child_posts' => cards(child_posts), 'parent_posts' => cards(parent_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| 'child_posts' => cards(child_posts, host:),
cards(posts) 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
}, cards(posts, host:)
'related' => cards(related)) },
'related' => cards(related, host:))
else
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
end end
def card post def card post, host: nil
common(post).merge('parent_posts' => [], 'child_posts' => []) if host.present?
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
else
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
end end
def cards posts def cards posts, host: nil
posts.map { |post| card(post) } if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
end end
def many posts, current_user = nil def many posts, current_user = nil, host: nil
posts.map { |p| base(p, current_user) } if host.present?
posts.map { |p| base(p, current_user, host:) }
else
posts.map { |p| base(p, current_user) }
end
end end
def common post def common post, host: nil
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] } BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post)) .merge(
'thumbnail' =>
if host.present?
thumbnail_url(post, host:)
else
thumbnail_url(post)
end)
end end
def tag_json post def tag_json post
post internal_tags =
.active_post_tags post
.post_tags
.reject { _1.tag.deprecated? } .reject { _1.tag.deprecated? }
.sort_by { _1.tag.name } .sort_by { _1.tag.name }
.map { |post_tag| .map { |post_tag|
TagRepr.inline(post_tag.tag).merge( TagRepr.inline(post_tag.tag).merge(
'children' => [], 'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
} }
external_tags =
post
.external_tags
.sort_by { _1.name }
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
internal_tags + external_tags
end end
def thumbnail_url post def thumbnail_url post, host: nil
return nil unless post.thumbnail.attached? return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false) options = { only_path: false }
rescue options[:host] = host if host.present?
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
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 nil
end end
end end
+9 -6
ファイルの表示
@@ -1,19 +1,22 @@
class NicoTagVersionRecorder < VersionRecorder class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user: def self.record! external_tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record! new(external_tag:, event_type:, created_by_user:).record!
end end
def initialize tag:, event_type:, created_by_user: def initialize external_tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:) super(record: external_tag, event_type:, created_by_user:)
end end
private private
def version_class = NicoTagVersion def version_class = NicoTagVersion
def version_association = :nico_tag_versions def version_association = :nico_tag_versions
def record_key = :tag def record_key = :external_tag
def snapshot_attributes def snapshot_attributes
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') } { name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end end
def tracks_version_no_on_record? = false
end end
+202
ファイルの表示
@@ -0,0 +1,202 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:, host: nil
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
@host = host
end
def run locale
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
workers = Array.new(2) do
Thread.new do
Rails.application.executor.wrap do
ActiveRecord::Base.connection_pool.with_connection do
actor = User.find(@actor_id)
loop do
index = nil
begin
index = mutex.synchronize do
current = next_index
next_index += 1
current
end
break if index >= @posts.length
attributes = @posts[index]
results[index] = create_row(locale, actor, attributes, index)
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name,
message: e.message,
index: }.to_json }")
results[index] = {
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
end
end
end
end
end
workers.each(&:join)
results.each_index do |index|
next if results[index].present?
results[index] = {
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
{ results: }
end
private
def create_row locale, actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes),
host: @host).run
if preflight[:existing_post_id].present?
return {
status: 'skipped',
existing_post_id: preflight[:existing_post_id],
existing_post: preflight[:existing_post] }
end
post = PostCreator.new(
actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!(locale)
result = {
status: 'created',
post: { id: post.id } }
result[:field_warnings] = preflight[:field_warnings] if preflight[:field_warnings].present?
result[:base_warnings] = preflight[:base_warnings] if preflight[:base_warnings].present?
result
rescue PostCreatePreflight::ValidationFailed => e
{
status: 'failed',
recoverable: true,
errors: e.fields,
base_errors: e.base_errors }
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(attributes, e.record)
if existing_post.present?
return {
status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post }
end
{
status: 'failed',
recoverable: true,
errors: e.record.errors.to_hash,
base_errors: e.record.errors[:base] }
rescue ActiveRecord::RecordNotUnique => e
if e.message.include?('index_posts_on_url')
existing_post = existing_post_for_race(attributes)
return {
status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post } if existing_post.present?
end
Rails.logger.error(
"post_bulk_creator_record_not_unique #{ { error: e.class.name,
message: e.message }.to_json }")
{
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
rescue Tag::NicoTagNormalisationError
{
status: 'failed',
recoverable: true,
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: [] }
rescue Tag::DeprecatedTagNormalisationError
{
status: 'failed',
recoverable: true,
errors: { tags: ['廃止済みタグは付与できません.'] },
base_errors: [] }
rescue PostCreator::VideoMsParseError
{
status: 'failed',
recoverable: true,
errors: { video_ms: ['動画時間の記法が不正です.'] },
base_errors: [] }
rescue Post::RemoteThumbnailFetchFailed
{
status: 'failed',
recoverable: true,
errors: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] },
base_errors: [] }
rescue ArgumentError => e
{
status: 'failed',
recoverable: true,
errors: { base: [e.message] },
base_errors: [] }
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_failure #{ { error: e.class.name,
message: e.message }.to_json }")
{
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
end
def normalised_attributes attributes, preflight, index
{
url: preflight[:url],
title: preflight[:title],
thumbnail_base: preflight[:thumbnail_base],
thumbnail: thumbnail_for(index, attributes),
tags: preflight[:tags],
parent_post_ids: preflight[:parent_post_ids],
original_created_from: preflight[:original_created_from],
original_created_before: preflight[:original_created_before],
duration: preflight[:duration],
video_ms: preflight[:video_ms],
direct_tag_specs: preflight[:direct_tag_specs],
default_tag_specs: preflight[:default_tag_specs],
snapshot_tag_specs: preflight[:snapshot_tag_specs],
post_tag_specs: preflight[:post_tag_specs],
tag_sections: preflight[:tag_sections],
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
end
def thumbnail_for index, attributes
return nil if attributes['thumbnail_base'].present? || attributes[:thumbnail_base].present?
@thumbnails[index]
end
def existing_post_for_race attributes, record = nil
return nil if record.present? && !(record.errors.of_kind?(:url, :taken))
normal_url = PostUrlNormaliser.normalise(attributes['url'] || attributes[:url])
return nil if normal_url.blank?
compact_existing_post(Post.with_attached_thumbnail.find_by(url: normal_url))
end
def compact_existing_post post
PostCompactRepr.base(post, host: @host)
end
end
+274
ファイルの表示
@@ -0,0 +1,274 @@
class PostCreatePlan
VIDEO_TAG_NAME = '動画'.freeze
TAGME_TAG_NAME = 'タグ希望'.freeze
NO_DEERJIKIST_TAG_NAME = 'ニジラー情報不詳'.freeze
def initialize attributes:
@attributes = attributes.symbolize_keys
@existing_tags_by_name = nil
end
def build! locale
direct_tag_specs, tag_sections = parse_direct_tag_specs(locale)
default_tag_specs = build_default_tag_specs(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(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 }
end
private
def tag_names = @attributes[:tags].to_s.split
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(locale, raw_name)
existing_tag = existing_tags_by_name(locale)[tag_name]
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
direct_tag_specs << {
name: tag_name,
category: (category || existing_tag&.category || 'general').to_sym }
if sections.present?
tag_sections[tag_name] ||= []
tag_sections[tag_name].concat(sections)
tag_sections[tag_name] = Tag.merge_section_ranges(tag_sections[tag_name])
tag_sections.delete(tag_name) if tag_sections[tag_name] == [[0, nil]]
end
end
[merge_tag_specs(direct_tag_specs), tag_sections]
end
def parse_raw_tag_name locale, raw_name
name = raw_name.to_s
prefix, category =
Tag::CATEGORY_PREFIXES.find {
name.downcase.start_with?(_1[0])
} || ['', nil]
name = name.sub(/\A#{ prefix }/i, '')
sections = []
while match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections << Tag.normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
[resolved_tag_name(locale, name), category&.to_sym, sections]
end
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?(locale, _1) }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
end
default_tag_specs
end
def validate_new_tag_specs! specs
Array(specs).each do |spec|
next if existing_tags_by_name.key?(spec[:name])
validate_new_tag_spec!(spec)
end
end
def validate_new_tag_spec! spec
tag_name = TagName.new(name: spec[:name])
tag = Tag.new(category: spec[:category], tag_name:)
return if tag_name.valid? && tag.valid?
post = Post.new
tag_name.errors[:name].each do |message|
post.errors.add :tags, "#{ spec[:name] }: #{ message }"
end
tag.errors.each do |error|
next if error.attribute == :tag_name
post.errors.add :tags, "#{ spec[:name] }: #{ error.message }"
end
raise ActiveRecord::RecordInvalid, post
end
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 }
}
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
def merge_tag_specs specs
specs.each_with_object({ }) { |spec, merged|
merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]]
else
{
name: spec[:name],
category: spec[:category] }
end
}.values.sort_by { _1[:name] }
end
def existing_tags_by_name locale
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
end
end
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(locale).keys
return if missing_names.empty?
existing_tags_by_name(locale).merge!(
Tag.joins(:tag_name)
.where(tag_names: { name: missing_names })
.index_by(&:name))
end
def canonical_tag_name_without_sections locale, raw_name
name, = parse_raw_tag_name(locale, raw_name)
name
end
def deerjikist_tag_spec? locale, spec
return true if spec[:category] == :deerjikist
existing_tags_by_name(locale)[spec[:name]]&.deerjikist?
end
def normalise_parent_post_ids
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq.sort
end
def validate_parent_post_ids! ids
missing = ids - Post.where(id: ids).pluck(:id)
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
end
def serialised_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
}.sort.join(' ')
end
def display_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
{
name: spec[:name],
category: spec[:category].to_s,
section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
}.sort_by { _1[:name] }
end
def section_literal range
begin_ms, end_ms = range
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise PostCreator::VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise PostCreator::VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise PostCreator::VideoMsParseError
end
def validate_video_sections! video_ms, tag_sections
return unless video_ms
tag_sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
def resolved_tag_name locale, name
tag_name = TagName.find_by!(language_code: locale.language_code, name:)
unless tag_name.primary_flg
tag_name = TagName.find_by!(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)
end
tag_name.name
end
end
+141
ファイルの表示
@@ -0,0 +1,141 @@
class PostCreatePreflight
class ValidationFailed < StandardError
attr_reader :fields, :base_errors
def initialize fields: { }, base_errors: []
super('入力内容を確認してください.')
@fields = fields
@base_errors = base_errors
end
end
def initialize attributes:, thumbnail: nil, host: nil
@attributes = attributes.symbolize_keys
@thumbnail = thumbnail
@host = host
end
def run
preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row],
fetch_metadata: false).first
if preview[:existing_post_id].present?
return {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
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'],
display_tags: preview[:display_tags] || [],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
if preview[:validation_errors].present?
raise ValidationFailed.new(fields: preview[:validation_errors])
end
validate_thumbnail_upload!
plan = PostCreatePlan.new(
attributes: {
url: preview[:url],
title: preview[:attributes]['title'],
thumbnail_base: preview[:attributes]['thumbnail_base'],
tags: preview[:attributes]['tags'],
parent_post_ids: preview[:attributes]['parent_post_ids'],
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!
{
url: plan[:url],
title: plan[:title],
thumbnail_base: plan[:thumbnail_base],
tags: plan[:tags],
parent_post_ids: plan[:parent_post_ids],
original_created_from: plan[:original_created_from],
original_created_before: plan[:original_created_before],
duration: plan[:duration],
video_ms: plan[:video_ms],
display_tags: plan[:display_tags],
direct_tag_specs: plan[:direct_tag_specs],
default_tag_specs: plan[:default_tag_specs],
snapshot_tag_specs: plan[:snapshot_tag_specs],
post_tag_specs: plan[:post_tag_specs],
tag_sections: plan[:tag_sections],
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
existing_post: existing_post_compact(preview[:existing_post_id]) }
end
private
def preview_row
{
source_row: 1,
url: @attributes[:url].to_s,
attributes: {
'title' => @attributes[:title].to_s,
'thumbnail_base' => @attributes[:thumbnail_base].to_s,
'original_created_from' => @attributes[:original_created_from].to_s,
'original_created_before' => @attributes[:original_created_before].to_s,
'duration' => @attributes[:duration].to_s,
'video_ms' => @attributes[:video_ms],
'tags' => @attributes[:tags].to_s,
'parent_post_ids' => parent_post_ids_text },
provenance: {
'url' => 'manual',
'title' => 'manual',
'thumbnail_base' => 'manual',
'original_created_from' => 'manual',
'original_created_before' => 'manual',
'duration' => 'manual',
'video_ms' => 'manual',
'tags' => 'manual',
'parent_post_ids' => 'manual' },
tag_sources: {
'automatic' => '',
'manual' => @attributes[:tags].to_s } }
end
def parent_post_ids_text
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.join(' ')
end
def validate_thumbnail_upload!
return if @attributes[:thumbnail_base].present?
return if @thumbnail.blank?
PostThumbnailUploadValidator.validate!(@thumbnail)
rescue PostThumbnailUploadValidator::InvalidUpload => e
raise ValidationFailed.new(fields: { thumbnail: [e.message] })
end
def existing_post_compact post_id
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: @host)
end
def final_field_warnings field_warnings
thumbnail_warnings = (field_warnings['thumbnail_base'] || []).reject { _1 == 'サムネールなし' }
if @attributes[:thumbnail_base].blank? && @thumbnail.blank?
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
end
next_warnings = field_warnings.except('thumbnail_base')
next_warnings['thumbnail_base'] = thumbnail_warnings if thumbnail_warnings.present?
next_warnings
end
end
+86 -66
ファイルの表示
@@ -9,104 +9,125 @@ class PostCreator
@field_warnings = { } @field_warnings = { }
end end
def create! def create! locale
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence, post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url], url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence, thumbnail_base: @attributes[:thumbnail_base].presence,
uploaded_user: @actor, uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence, original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence) original_created_before: @attributes[:original_created_before].presence)
attach_thumbnail!(post)
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
{ tags:, sections: } snapshot_tags = planned_snapshot_tags(locale)
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor) post_tags = planned_post_tags(locale)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) sections = planned_sections(locale)
post.video_ms = normalise_video_ms(tags) TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
validate_video_sections!(post.video_ms, sections) post.video_ms = planned_video_ms(locale)
post.save! post.save!
sync_post_tags!(post, tags, sections) sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, planned_parent_post_ids(locale))
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end end
post post
rescue StandardError
post&.thumbnail&.purge if post&.thumbnail&.attached?
raise
end end
private private
def attach_thumbnail! post def prepare_thumbnail_attachment
thumbnail = @attributes[:thumbnail] PostThumbnailAttachmentBuilder.build(
if thumbnail.present? thumbnail: @attributes[:thumbnail],
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) thumbnail_base: @attributes[:thumbnail_base].presence)
return
end
thumbnail_base = post.thumbnail_base
return if thumbnail_base.blank?
post.attach_thumbnail_from_url!(thumbnail_base)
rescue Post::RemoteThumbnailFetchFailed => e
@field_warnings[:thumbnail_base] = [e.message]
end end
def tag_names = @attributes[:tags].to_s.split def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags]
def parent_post_ids def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags]
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections]
}.uniq
def planned_parent_post_ids locale
planned_create_attributes(locale)[:normalised_parent_post_ids]
end end
def normalise_video_ms tags def planned_video_ms locale
return nil unless tags.any? { _1.id == Tag.video.id } planned_create_attributes(locale)[:video_ms]
video_ms = @attributes[:video_ms]
if video_ms.present?
value = Integer(video_ms, exception: false)
raise VideoMsParseError unless value&.positive?
return value
end
duration = @attributes[:duration]
return nil if duration.blank?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive?
value
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end end
def validate_video_sections! video_ms, sections def planned_create_attributes locale
return unless video_ms @planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs)
sections.each_value do |ranges| snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || [])
ranges.each do |begin_ms, end_ms| post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || [])
post = Post.new {
if begin_ms >= video_ms snapshot_tags: snapshot_tags,
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.' post_tags: post_tags,
raise ActiveRecord::RecordInvalid, post tag_sections: materialise_sections(
end @attributes[:tag_sections] || { },
if end_ms && end_ms > video_ms snapshot_tags,
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.' post_tags),
raise ActiveRecord::RecordInvalid, post normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
end video_ms: @attributes[:video_ms] }
else
build_materialised_plan
end end
end 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] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
plan[:tag_sections] || { },
snapshot_tags,
post_tags),
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
video_ms: plan[:video_ms] }
end
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!(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)
snapshot_tags.each do |tag|
tags_by_name[tag.name] ||= tag
end
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
tag = tags_by_name[tag_name.to_s]
next if tag.nil?
sections[tag.id] = Array(ranges).map { |range| [range[0], range[1]] }
end
end
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag| Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor) PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end end
PostTagSection.where(post_id: post.id).destroy_all PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges| sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms| ranges.each do |begin_ms, end_ms|
@@ -116,10 +137,9 @@ class PostCreator
end_ms:) end_ms:)
end end
end end
PostTag.where(post_id: post.id, PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag| tag_id: (current_ids - desired_ids).to_a).destroy_all
post_tag.discard_by!(@actor)
end
end end
def sync_parent_posts! post, ids def sync_parent_posts! post, ids
+45 -26
ファイルの表示
@@ -3,13 +3,14 @@ require 'timeout'
class PostImportPreviewer class PostImportPreviewer
FIELDS = [ FIELDS = [
'title', 'title',
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'duration', 'video_ms',
'tags', 'duration',
'parent_post_ids'].freeze 'tags',
'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
@@ -125,7 +126,6 @@ class PostImportPreviewer
validate_basic_data(attributes, validation_errors) validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']), validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validation_errors, validation_errors,
field_warnings,
known_tags) known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids) validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
attributes.delete('url') attributes.delete('url')
@@ -162,6 +162,8 @@ class PostImportPreviewer
normalised = normalised =
if field == 'duration' if field == 'duration'
normalise_duration_attribute(value) normalise_duration_attribute(value)
elsif field == 'video_ms'
value.nil? ? '' : value.to_s
else else
value.to_s value.to_s
end end
@@ -194,7 +196,7 @@ class PostImportPreviewer
def clear_automatic_values! attributes, provenance, tag_sources def clear_automatic_values! attributes, provenance, tag_sources
['title', 'thumbnail_base', 'original_created_from', ['title', 'thumbnail_base', 'original_created_from',
'original_created_before', 'duration'].each do |field| 'original_created_before', 'video_ms', 'duration'].each do |field|
attributes[field] = '' if provenance[field] == 'automatic' attributes[field] = '' if provenance[field] == 'automatic'
end end
tag_sources['automatic'] = '' tag_sources['automatic'] = ''
@@ -223,15 +225,15 @@ class PostImportPreviewer
end end
{ data:, warnings:, validation_errors: { } } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed, rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -252,7 +254,15 @@ class PostImportPreviewer
def sanitise_metadata_url value def sanitise_metadata_url value
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
PostUrlNormaliser.normalise(value) stripped = value.strip
return nil if stripped.blank?
uri = URI.parse(stripped)
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
stripped
rescue URI::InvalidURIError
nil
end end
def sanitise_metadata_time value def sanitise_metadata_time value
@@ -325,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
fetch_metadata(url) fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\ "post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -439,7 +449,7 @@ class PostImportPreviewer
[] []
end end
def validate_preview_tags raw, errors, field_warnings, known_tags def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split names = raw.to_s.split
return if names.empty? return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') } if names.any? { _1.downcase.start_with?('nico:') }
@@ -451,11 +461,6 @@ class PostImportPreviewer
existing = parsed.filter_map { known_tags[_1] } existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name) deprecated = existing.select(&:deprecated?).map(&:name)
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
known = existing.reject(&:deprecated?).map(&:name)
new_tags = parsed.uniq - known
if new_tags.present?
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
end
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.'] errors[:tags] = ['タグ区間の記法が不正です.']
end end
@@ -466,7 +471,7 @@ class PostImportPreviewer
thumbnail_base: attributes['thumbnail_base'].presence, thumbnail_base: attributes['thumbnail_base'].presence,
original_created_from: attributes['original_created_from'].presence, original_created_from: attributes['original_created_from'].presence,
original_created_before: attributes['original_created_before'].presence, original_created_before: attributes['original_created_before'].presence,
video_ms: parse_duration(attributes['duration'], errors)) video_ms: parse_video_ms(attributes, errors))
post.valid? post.valid?
post.errors.each do |error| post.errors.each do |error|
next if error.attribute == :url && error.type == :taken next if error.attribute == :url && error.type == :taken
@@ -475,7 +480,21 @@ class PostImportPreviewer
end end
end end
def parse_duration value, errors def parse_video_ms attributes, errors
return nil unless attributes['tags'].to_s.split.include?('動画')
video_ms = attributes['video_ms']
if video_ms.present?
value = Integer(video_ms, exception: false)
if value&.positive?
return value
end
errors[:video_ms] = ['動画時間の記法が不正です.']
return nil
end
value = attributes['duration']
return nil if value.blank? return nil if value.blank?
Tag.time_to_ms!(value.to_s, tag_name: '動画時間') Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
-190
ファイルの表示
@@ -1,190 +0,0 @@
class PostImportRowNormaliser
ORIGINS = ['automatic', 'manual'].freeze
STRING_FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'parent_post_ids'].freeze
FLEXIBLE_FIELDS = ['video_ms'].freeze
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
def self.normalise! rows, allow_warning_fields: false
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
source_rows = normalised_rows.map { _1['source_row'] }
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込データが大きすぎます.'
end
normalised_rows
end
def self.normalise_row! row, allow_warning_fields:
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
raise ArgumentError, '取込行の形式が不正です.'
end
parameters =
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
normalise_url!(normalised['url'])
normalise_metadata_url!(normalised['metadata_url'])
normalise_attributes!(normalised.fetch('attributes', { }))
normalise_provenance!(normalised.fetch('provenance', { }))
normalise_tag_sources!(normalised['tag_sources'])
normalise_warning_values!(normalised, allow_warning_fields:)
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
raise ArgumentError, '取込行が大きすぎます.'
end
normalised
end
def self.permitted_keys allow_warning_fields
keys = [
:source_row,
:sourceRow,
:url,
:metadata_url,
:metadataUrl,
{ attributes: {} },
{ provenance: {} },
{ tag_sources: {} },
{ tagSources: {} }]
return keys unless allow_warning_fields
keys + [
{ field_warnings: {} },
{ fieldWarnings: {} },
{ base_warnings: [] },
{ baseWarnings: [] }]
end
private_class_method :permitted_keys
def self.normalise_source_row! value
source_row = Integer(value, exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
source_row
end
private_class_method :normalise_source_row!
def self.normalise_url! value
unless value.is_a?(String)
raise ArgumentError, 'URL の形式が不正です.'
end
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
end
private_class_method :normalise_url!
def self.normalise_metadata_url! value
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'metadata_url が長すぎます.'
end
end
private_class_method :normalise_metadata_url!
def self.normalise_attributes! attributes
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
attributes.each do |key, value|
case key
when *STRING_FIELDS
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
when *FLEXIBLE_FIELDS
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
raise ArgumentError, '取込項目の型が不正です.'
end
end
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, '取込項目が大きすぎます.'
end
end
end
private_class_method :normalise_attributes!
def self.normalise_provenance! provenance
unless provenance.is_a?(Hash)
raise ArgumentError, 'provenance の形式が不正です.'
end
allowed = ATTRIBUTE_FIELDS + ['url']
unless (provenance.keys - allowed).empty?
raise ArgumentError, '値の由来が不正です.'
end
unless provenance.values.all? { ORIGINS.include?(_1) }
raise ArgumentError, '値の由来が不正です.'
end
end
private_class_method :normalise_provenance!
def self.normalise_tag_sources! tag_sources
return if tag_sources.nil?
unless tag_sources.is_a?(Hash)
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless (tag_sources.keys - ORIGINS).empty?
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless tag_sources.values.all? { _1.is_a?(String) }
raise ArgumentError, 'タグ由来の形式が不正です.'
end
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, 'タグ由来が大きすぎます.'
end
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'タグ由来が大きすぎます.'
end
end
private_class_method :normalise_tag_sources!
def self.normalise_warning_values! normalised, allow_warning_fields:
return unless allow_warning_fields
field_warnings = normalised['field_warnings']
unless field_warnings.nil? || field_warnings.is_a?(Hash)
raise ArgumentError, '警告の形式が不正です.'
end
field_warnings&.each do |key, values|
unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
raise ArgumentError, '警告の形式が不正です.'
end
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
base_warnings = normalised['base_warnings']
return if base_warnings.nil?
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.'
end
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end
private_class_method :normalise_warning_values!
def self.row_bytesize row
row.to_json.bytesize
end
private_class_method :row_bytesize
end
-109
ファイルの表示
@@ -1,109 +0,0 @@
class PostImportRunner
def initialize actor:, rows:
@actor = actor
@rows = rows
end
def run
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
fetch_metadata: false)
preview_map = previews.index_by { _1[:source_row] }
results = normalised_rows.map do |row|
run_row(row, preview_map.fetch(row['source_row']))
end
{ created: results.count { _1[:status] == 'created' },
skipped: results.count { _1[:status] == 'skipped' },
failed: results.count { _1[:status] == 'failed' },
rows: results }
end
private
def run_row row, preview
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return { source_row: row['source_row'],
status: 'failed',
errors: preview[:validation_errors],
recoverable: true } if preview[:validation_errors].present?
if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: preview[:existing_post_id] }
end
attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url']
creator = PostCreator.new(actor: @actor, attributes:)
post = creator.create!
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
if creator.field_warnings.present?
result[:field_warnings] = creator.field_warnings
end
result
rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(row, e.record)
if existing_post
return { source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
end
{ source_row: row['source_row'],
status: 'failed',
errors: e.record.errors.to_hash,
recoverable: true }
rescue ActiveRecord::RecordNotUnique => e
raise unless url_record_not_unique?(e)
existing_post = existing_post_for_race(row)
raise unless existing_post
{ source_row: row['source_row'],
status: 'skipped',
existing_post_id: existing_post.id }
rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
recoverable: true }
rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'],
status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] },
recoverable: true }
rescue PostCreator::VideoMsParseError
{ source_row: row['source_row'],
status: 'failed',
errors: { duration: ['動画時間の記法が不正です.'] },
recoverable: true }
rescue ArgumentError
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['入力値が不正です.'] },
recoverable: true }
rescue StandardError => e
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
message: e.message }.to_json }")
{ source_row: row['source_row'],
status: 'failed',
errors: { base: ['登録中にエラーが発生しました.'] } }
end
def existing_post_for_race row, record = nil
if record && !(record.errors.of_kind?(:url, :taken))
return nil
end
normal_url = PostUrlNormaliser.normalise(row['url'])
return nil if normal_url.blank?
Post.find_by(url: normal_url)
end
def url_record_not_unique? error
error.message.include?('index_posts_on_url')
end
end
-25
ファイルの表示
@@ -1,25 +0,0 @@
class PostImportUrlListParser
MAX_ROWS = 100
MAX_BYTES = 1.megabyte
MAX_URL_BYTES = 20.kilobytes
def self.parse source
raw = source.to_s
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
url = line.strip
next if url.blank?
if url.bytesize > MAX_URL_BYTES
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
end
{ source_row: index + 1, url: }
}
raise ArgumentError, 'URL を入力してください.' if rows.empty?
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
rows
end
end
+23 -4
ファイルの表示
@@ -3,10 +3,9 @@ require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
Regexp.new( /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \ (?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \ (Z|[+-]\d{2}:?\d{2})?\z/x
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url) uri, = Preview::UrlSafety.validate(raw_url)
@@ -32,6 +31,7 @@ class PostMetadataFetcher
original_created_from: serialise_time(created_range&.first), original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last), original_created_before: serialise_time(created_range&.last),
duration: serialise_duration(duration), duration: serialise_duration(duration),
display_tags: display_tags(platform_tags),
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -42,6 +42,24 @@ class PostMetadataFetcher
[] []
end end
def self.display_tags names
return [] if names.blank?
existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: names })
.index_by(&:name)
names.map { |name|
tag = existing_tags[name]
{
name: name,
category: (tag&.category || 'meta'),
section_literals: [] }
}
end
def self.original_created_range value def self.original_created_range value
return nil if value.blank? return nil if value.blank?
@@ -168,6 +186,7 @@ class PostMetadataFetcher
end end
private_class_method :platform_tags, private_class_method :platform_tags,
:display_tags,
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_nanoseconds, :parse_nanoseconds,
+11
ファイルの表示
@@ -0,0 +1,11 @@
class PostThumbnailAttachmentBuilder
def self.build thumbnail:, thumbnail_base:
if thumbnail_base.present?
return Post.remote_thumbnail_attachment(thumbnail_base)
end
return nil if thumbnail.blank?
Post.resized_thumbnail_attachment(thumbnail)
end
end
+34
ファイルの表示
@@ -0,0 +1,34 @@
class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::RASTER_IMAGE_CONTENT_TYPES.freeze
class InvalidUpload < StandardError; end
def self.validate! thumbnail
return if thumbnail.blank?
unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail upload が不正です.'
end
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
bytes = thumbnail.read
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(bytes)
unless Post.raster_thumbnail_bytes?(bytes)
raise InvalidUpload, 'サムネイル画像の形式が不正です.'
end
attachment = Post.resized_thumbnail_attachment(
StringIO.new(bytes),
content_type: thumbnail.content_type)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error, Timeout::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
ensure
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
end
def self.allowed_content_type? content_type
mime_type = content_type.to_s.split(';', 2).first.to_s.downcase.strip
ALLOWED_CONTENT_TYPES.include?(mime_type)
end
end
+1
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionRecorder < VersionRecorder
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms, video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
tags_json: @record.snapshot_tags_json,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before } original_created_before: @record.original_created_before }
+8 -10
ファイルの表示
@@ -1,7 +1,7 @@
module Preview module Preview
class ThumbnailFetcher class ThumbnailFetcher
class GenerationFailed < StandardError; end class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [ RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp' 'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze ].freeze
HTML_MAX_BYTES = 1.megabyte HTML_MAX_BYTES = 1.megabyte
@@ -28,7 +28,9 @@ module Preview
def self.fetch_image_response(raw_url) def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url) uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s) response = HttpFetcher.fetch(uri.to_s)
unless allowed_image_content_type?(response.content_type) unless Post.remote_thumbnail_image_bytes?(
response.body,
content_type: response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end end
@@ -51,7 +53,9 @@ module Preview
return nil if url.blank? return nil if url.blank?
response = HttpFetcher.fetch(url) response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type) return nil unless Post.remote_thumbnail_image_bytes?(
response.body,
content_type: response.content_type)
response.body response.body
rescue HttpFetcher::FetchTimeout rescue HttpFetcher::FetchTimeout
@@ -92,13 +96,7 @@ module Preview
nil nil
end end
def self.allowed_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!, private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url, :niconico_thumbnail_url
:allowed_image_content_type?
end end
end end
+4 -20
ファイルの表示
@@ -1,32 +1,16 @@
class TagVersioning class TagVersioning
def self.record! tag, event_type:, created_by_user: def self.record! tag, event_type:, created_by_user:
if tag.nico? TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end end
def self.ensure_snapshot! tag, created_by_user: def self.ensure_snapshot! tag, created_by_user:
if tag.nico? return if tag.tag_versions.exists?
return if tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) TagVersionRecorder.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
end end
def self.record_tag_snapshot! tag, created_by_user: def self.record_tag_snapshot! tag, created_by_user:
event_type = event_type = tag.tag_versions.exists? ? :update : :create
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:) record!(tag, event_type:, created_by_user:)
end end
+5 -1
ファイルの表示
@@ -47,10 +47,14 @@ class VersionRecorder
end end
def update_record_version_no! version_no def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:) @record.update_columns(version_no:)
@record.version_no = version_no @record.version_no = version_no
end end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest def validate_version_sequence! latest
if !(latest) && @event_type != 'create' if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create" raise "#{ version_class.name } first event must be create"
@@ -60,7 +64,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists" raise "#{ version_class.name } create event already exists"
end end
return unless latest return if !(latest) || !(tracks_version_no_on_record?)
if @record.version_no != latest.version_no if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil 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 desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.discard_by!(nil) pt.destroy!
end end
end end
+8 -4
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time" require 'active_support/core_ext/integer/time'
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # 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. # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching. # Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
@@ -36,7 +36,11 @@ Rails.application.configure do
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates. # Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 } config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
Rails.application.routes.default_url_options.merge!(
host: 'localhost',
port: 3002,
protocol: 'http')
# Print deprecation notices to the Rails logger. # Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log
+3 -9
ファイルの表示
@@ -1,5 +1,5 @@
Rails.application.routes.draw do 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 scope 'tags/:parent_id/children', controller: :tag_children do
post ':child_id', action: :create post ':child_id', action: :create
@@ -31,13 +31,6 @@ Rails.application.routes.draw do
scope :preview, controller: :preview do scope :preview, controller: :preview do
get :title get :title
get :thumbnail get :thumbnail
get :image
end
scope 'posts/import', controller: :post_imports do
post :preview
post :validate
post '', action: :create
end end
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
@@ -59,8 +52,9 @@ Rails.application.routes.draw do
resources :posts, only: [:index, :show, :create, :update] do resources :posts, only: [:index, :show, :create, :update] do
collection do collection do
get :metadata
post :bulk
get :random get :random
get :changes
get :versions, to: 'post_versions#index' get :versions, to: 'post_versions#index'
end 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
+20
ファイルの表示
@@ -0,0 +1,20 @@
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
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 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.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
end
execute <<~SQL
INSERT INTO
locales(code, language_code, script_code, name, created_at)
VALUES
('ja', 'ja', 'Jpan', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :locales
end
end
+59
ファイルの表示
@@ -0,0 +1,59 @@
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_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
end
def down
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, :primary_flg
remove_column :tag_names, :script_code
remove_column :tag_names, :language_code
remove_reference :tag_names, :tag, foreign_key: true
end
end
生成ファイル
+75 -38
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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_030000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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" t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end 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| create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false t.string "model", null: false
t.integer "input_tokens", default: 0, null: false t.integer "input_tokens", default: 0, null: false
@@ -130,6 +138,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end 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.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"], name: "index_locales_on_language_code"
t.index ["script_code"], name: "fk_rails_0b74ce96a8"
end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false t.string "profile", default: "legacy_drive", null: false
@@ -281,6 +307,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" t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end 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| 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 "post_id", null: false
t.bigint "parent_post_id", null: false t.bigint "parent_post_id", null: false
@@ -311,23 +344,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" t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end 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 "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "created_user_id" t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false 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 ["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" t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end end
@@ -349,6 +371,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.text "tags", null: false t.text "tags", null: false
t.json "tags_json", null: false
t.text "parent_post_ids", null: false t.text "parent_post_ids", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
@@ -362,6 +385,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 "(`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 "`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 "`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 end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -382,6 +406,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end 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"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -393,19 +424,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end 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| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -427,14 +445,20 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
end end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| 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 "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.bigint "canonical_id" t.bigint "canonical_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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 ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at" t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["name"], name: "index_tag_names_on_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"
end end
create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -470,10 +494,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" 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.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 "(`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" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -614,6 +636,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end 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| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -699,6 +734,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id" 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", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id" add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
@@ -707,37 +744,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_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" 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: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_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", "materials", column: "parent_id"
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_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"
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 "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"
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts" 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_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" 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", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id" 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", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" 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 "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
+58 -33
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil 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 desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -30,9 +30,28 @@ namespace :nico do
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!)
pt.discard_by!(nil) 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 end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end end
mysql_user = ENV['MYSQL_USER'] 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]) sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end end
tags = post.tags
# 既存のタグ Id. 集合 # 既存のタグ Id. 集合
kept_tag_ids = tags.pluck(:id).to_set kept_tag_ids = post.tags.pluck(:id).to_set
# うち内部タグ Id. 集合
kept_non_nico_tag_ids = tags.not_nico.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. のリスト # 記載すべき内部タグ Id. のリスト
desired_non_nico_tag_ids = [] desired_tag_ids = kept_tag_ids.to_a
datum['tags'].each do |raw| datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }") name = TagNameSanitisationRule.sanitise("nico:#{ raw }").delete_prefix('nico:')
tag = Tag.find_or_create_by_tag_name!(name, category: :nico) tag = ExternalTag.find_or_create_by!(platform: :nico, name:)
event_type = tag.nico_tag_versions.exists? ? :update : :create unless tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) 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) unless tag.id.in?(kept_external_tag_ids)
desired_non_nico_tag_ids.concat(linked_ids) desired_tag_ids.concat(tag.linked_tags.pluck(:id))
desired_nico_tag_based_ids.concat(linked_ids)
end end
end end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist if deerjikist
desired_non_nico_tag_ids << deerjikist.tag_id desired_tag_ids << deerjikist.tag_id
desired_nico_tag_based_ids << deerjikist.tag_id elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?)
elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?) desired_tag_ids << Tag.no_deerjikist.id
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
end 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 # 外部タグの記載に際しては “bot 操作” タグを記載しなぃ.
desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a) if kept_tag_ids != desired_tag_ids.to_set
desired_non_nico_tag_ids.uniq! desired_tag_ids << Tag.bot.id
if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set desired_tag_ids.uniq!
desired_all_tag_ids << Tag.bot.id
end 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 if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) 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.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end 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
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+8 -1
ファイルの表示
@@ -1,5 +1,12 @@
FactoryBot.define do FactoryBot.define do
factory :tag_name do factory :tag_name do
name { "tag-#{SecureRandom.hex(4)}" } language_code { 'ja' }
script_code { 'Jpan' }
primary_flg { true }
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
trait :alias do
primary_flg { false }
end
end end
end end
+3 -5
ファイルの表示
@@ -6,17 +6,15 @@ FactoryBot.define do
category { :general } category { :general }
post_count { 0 } post_count { 0 }
association :tag_name association :tag_name
after(:build) do |tag, evaluator| after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present? tag.name = evaluator.name if evaluator.name.present?
end end
trait :nico do after(:create) do |tag|
category { :nico } tag.tag_name.update!(tag:) if tag.tag_name.tag_id != tag.id
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
end
end end
end 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
+69
ファイルの表示
@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
def prepare_french_reference!
Language.find_or_create_by!(code: 'fr') { _1.name = 'French' }
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
end
def create_french_locale!
prepare_french_reference!
described_class.create!(code: 'fr', language_code: 'fr',
script_code: 'Latn', name: 'French')
end
it 'generates a primary name for every existing tag using the name generator' do
first = create(:tag, name: 'first_existing_tag')
second = create(:tag, 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, first.name)
.and_return('name_from_generator')
locale = create_french_locale!
expect(TagName).to have_received(:generate_name).with(locale, first, first.name)
expect(TagName).to have_received(:generate_name).with(locale, second, second.name)
expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'fr', script_code: 'Latn',
primary_flg: true)
expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'fr',
script_code: 'Latn', primary_flg: true)
expect(TagName.where(language_code: 'fr', 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
prepare_french_reference!
tag = create(:tag, name: 'already_named')
existing = create(:tag_name, name: 'nom_existant', tag:,
language_code: 'fr', script_code: 'Latn')
create_french_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true)
end
it 'creates a primary name when the language has only an alias' do
prepare_french_reference!
tag = create(:tag, name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_fr',
tag:,
language_code: 'fr', script_code: 'Latn')
create_french_locale!
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'fr', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr',
script_code: 'Latn', primary_flg: true)
expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1)
end
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) } let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) } let(:tag) { create(:tag, name: 'export_item', category: :material) }
let(:material) do let(:material) do
Material.create!(tag:, url: 'https://example.com/material', Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user) 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
+30 -11
ファイルの表示
@@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all PostUrlSanitisationRule.unscoped.delete_all
end 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' => tag.name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!( PostUrlSanitisationRule.create!(
@@ -110,7 +129,7 @@ RSpec.describe Post, type: :model do
end end
describe '.resized_thumbnail_attachment' do describe '.resized_thumbnail_attachment' do
it 'centre-crops a wide image to 180x180 without distorting it' do it 'fits a wide image within 180x180 without distorting it' do
blob = image_blob( blob = image_blob(
width: 360, width: 360,
height: 180, height: 180,
@@ -123,13 +142,13 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 90])
expect_green(colour_at(image, 0, 90)) expect_red(colour_at(image, 0, 45))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 90, 45))
expect_green(colour_at(image, 179, 90)) expect_red(colour_at(image, 179, 45))
end end
it 'centre-crops a tall image to 180x180 without distorting it' do it 'fits a tall image within 180x180 without distorting it' do
blob = image_blob( blob = image_blob(
width: 180, width: 180,
height: 360, height: 360,
@@ -142,10 +161,10 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([90, 180])
expect_green(colour_at(image, 90, 0)) expect_red(colour_at(image, 45, 0))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 45, 90))
expect_green(colour_at(image, 90, 179)) expect_red(colour_at(image, 45, 179))
end end
it 'keeps a square image square without distortion' do it 'keeps a square image square without distortion' do
@@ -192,7 +211,7 @@ RSpec.describe Post, type: :model do
expect(post.thumbnail).to be_attached expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail) image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 135])
end end
it 'does not attach anything when thumbnail conversion fails' do it 'does not attach anything when thumbnail conversion fails' do
+79 -4
ファイルの表示
@@ -1,5 +1,73 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do 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 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 it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag) post_tag = create(:post_tag)
section = create(:post_tag_section, section = create(:post_tag_section,
@@ -12,18 +80,25 @@ RSpec.describe PostTag, type: :model do
end end
it 'does not load sections for another tag on the same post' do it 'does not load sections for another tag on the same post' do
post = create(:post) post_tag = create(:post_tag)
tag = create(:tag) post = post_tag.post
other_tag = create(:tag) 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, create(:post_tag_section,
post:, post:,
tag: other_tag, tag: other_tag,
begin_ms: 1000, begin_ms: 1000,
end_ms: 2000) end_ms: 2000)
expect(post_tag.sections).to be_empty expect(post_tag.reload.sections).to contain_exactly(own_section)
end end
it 'allows open-ended sections' do it 'allows open-ended sections' do
+3 -2
ファイルの表示
@@ -1,8 +1,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PostVersion, type: :model do RSpec.describe PostVersion, type: :model do
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } let!(:tag) { create(:tag, tag_name: tag_name, category: :general) }
let!(:post_record) do let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| 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, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from, original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
+74 -17
ファイルの表示
@@ -34,9 +34,41 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: '_', replacement: '') described_class.create!(priority: 10, source_pattern: '_', replacement: '')
end 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
japanese = 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(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
japanese = create(:tag, name: 'foobar')
english_name = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english = create(:tag, 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(japanese.reload.tag_name).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: japanese.id)
expect(english.reload.tag_name_id).to eq(english_name.id)
end
end
context 'when no conflicting tag_name exists' do context 'when no conflicting tag_name exists' do
let!(:tag_name) 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) tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end end
end end
@@ -47,23 +79,23 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
end end
context 'when a conflicting canonical tag_name exists' do context 'when a conflicting primary tag name exists' do
let!(:existing) { TagName.create!(name: 'foobar') } let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source) do 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) tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end end
end end
it 'deletes the source tag_name' do it 'deletes the source tag_name' do
described_class.apply! 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') expect(existing.reload.name).to eq('foobar')
end end
end end
context 'when the source tag_name has a tag and the existing one has no tag' do context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { TagName.create!(name: 'foobar') } let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source_tag) { create(:tag, name: 'tmp', category: :general) } let!(:source_tag) { create(:tag, name: 'tmp', category: :general) }
let!(:source_tag_name_id) { source_tag.tag_name_id } let!(:source_tag_name_id) { source_tag.tag_name_id }
@@ -73,18 +105,41 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'moves the tag to the existing tag_name' do it 'moves the tag to the existing tag_name' do
described_class.apply! described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id expect(source_tag.reload.tag_name_id).to eq(existing.id)
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(TagName.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!(: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_tag.tag_name)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end end
end end
context 'when both source and existing tag_names have tags' do context 'when both source and existing tag_names have tags' do
let!(:existing_tn) { TagName.create!(name: 'foobar') } let!(:existing_tn) { create(:tag_name, name: 'foobar') }
let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) } let!(:existing_tag) { create(:tag, tag_name: existing_tn, category: :general) }
let!(:source_tn) { TagName.create!(name: 'tmp') } let!(:source_tn) { create(:tag_name, name: 'tmp') }
let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) } let!(:source_tag) { create(:tag, tag_name: source_tn, category: :general) }
let!(:source_tag_name_id) { source_tn.id } let!(:source_tag_name_id) { source_tn.id }
before do before do
@@ -92,13 +147,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
it 'merges the source tag into the existing tag and deletes the source tag_name' do 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) post = create(:post)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id) PostTag.create!(post:, tag: source_tag)
described_class.apply! described_class.apply!
expect(Tag.exists?(source_tag.id)).to be(false) expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_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(existing_tag.reload.name).to eq('foobar')
end end
end end
end end
+94
ファイルの表示
@@ -0,0 +1,94 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'English locale'
it 'allows the same name in different languages' do
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 Japanese 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, tag_id: nil)
end
it 'persists both sides of the representative tag factory association' do
tag = create(:tag)
expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id)
expect(tag.tag_name.tag).to eq(tag)
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
.to eq(tag.tag_name)
end
it 'persists an alias owned by the same tag as its primary name' do
tag = create(:tag)
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(tag.tag_name)
end
describe '.canonicalise' do
it 'resolves the primary name of the alias language on the same tag' do
tag = create(:tag, name: '日本語正本')
english_primary = create(:tag_name, tag:, name: 'english_primary',
language_code: 'en', script_code: 'Latn')
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
japanese = create(:tag, name: 'japanese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, tag_name: english_name)
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.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(['japanese_canonical'])
end
end
end
+313 -117
ファイルの表示
@@ -1,20 +1,72 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do 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, 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 describe '.normalise_tags!' do
it 'canonicalises aliases in the supplied language only' do
Language.find_or_create_by!(code: 'en') { _1.name = 'English' }
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
Locale.insert_all!([
{ code: 'en', language_code: 'en', script_code: 'Latn',
name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en')
english_locale = Locale.find('en')
japanese = create(:tag, name: 'japanese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, tag_name: english_name)
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.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([japanese])
end
it 'rejects deprecated tags when deny_deprecated is enabled' do it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag') tag_name = create(:tag_name, name: 'normalise deprecated tag')
deprecated_tag = Tag.create!( deprecated_tag = create(:tag,
tag_name:, tag_name:,
category: :general, category: :general,
deprecated_at: 1.day.from_now deprecated_at: 1.day.from_now)
)
expect { expect {
described_class.normalise_tags!( described_class.normalise_tags!(Locale.nipponese,
[deprecated_tag.name], [deprecated_tag.name],
deny_deprecated: true deny_deprecated: true)
)
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
expect(error.tag_names).to eq([deprecated_tag.name]) expect(error.tag_names).to eq([deprecated_tag.name])
} }
@@ -22,88 +74,79 @@ RSpec.describe Tag, type: :model do
it 'rejects invalid section literals instead of treating them as zero' do it 'rejects invalid section literals instead of treating them as zero' do
expect { expect {
described_class.normalise_tags!( described_class.normalise_tags!(Locale.nipponese,
['normalise_invalid_section[1:aa-2:00]'], ['normalise_invalid_section[1:aa-2:00]'],
with_sections: true with_sections: true)
)
}.to raise_error(Tag::SectionLiteralParseError) }.to raise_error(Tag::SectionLiteralParseError)
end end
it 'parses open-ended section literals' do it 'parses open-ended section literals' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-]'], ['伊地知ニジカ[1:00-]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end end
it 'parses omitted begin as zero' do it 'parses omitted begin as zero' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-1:00]'], ['伊地知ニジカ[-1:00]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end end
it 'treats fully open section literals as plain tags' do it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-]'], ['伊地知ニジカ[-]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil expect(result.fetch(:sections)[tag.id]).to be_nil
end end
it 'treats [0:00-] as a plain tag' do it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[0:00-]'], ['伊地知ニジカ[0:00-]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil expect(result.fetch(:sections)[tag.id]).to be_nil
end end
it 'expands zero-width sections to one millisecond' do it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-1:00]'], ['伊地知ニジカ[1:00-1:00]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end end
it 'swaps reversed section boundaries' do it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[2:00-1:00]'], ['伊地知ニジカ[2:00-1:00]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end end
it 'merges open-ended sections over later bounded sections' do it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-][2:00-3:00]'], ['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end end
it 'merges adjacent bounded and open-ended sections' do it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!( result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-3:00][3:00-]'], ['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true with_sections: true)
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
@@ -159,17 +202,109 @@ RSpec.describe Tag, type: :model do
end end
end end
describe 'deprecated validation' do describe '.find_or_create_by_tag_name!' do
it 'rejects deprecated nico tags' do context 'with an explicit locale' do
tag = build( include_context 'English locale'
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
expect(tag).not_to be_valid it 'creates the representative name with locale attributes and ownership' do
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.') tag = described_class.find_or_create_by_tag_name!(
locale, 'english_name', category: :character)
expect(tag.tag_name.reload).to have_attributes(
language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character')
end
it 'creates separate tag identities for the same name in different languages' do
japanese = 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(japanese)
expect(english.tag_name.language_code).to eq('en')
expect(japanese.tag_name.language_code).to eq('ja')
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(japanese)
end
it 'preserves the V1 representative when looking up another primary language' do
tag = create(:tag, name: '日本語代表名')
representative_id = tag.tag_name_id
english = create(:tag_name, name: 'english_primary', tag:,
language_code: 'en', script_code: 'Latn')
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
tags = described_class.normalise_tags!(
locale, ['character:normalised_english'],
with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1)
expect(tags.first.tag_name).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(tag.name).to eq('lookup_new')
expect(tag.category).to eq('character')
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 = tag.tag_name
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(tag.reload.tag_name).to eq(primary)
end end
end end
@@ -185,19 +320,16 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } 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]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
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(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -206,39 +338,104 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) } let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_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]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_links = PostTag.where(post: post_record, tag: target_tag)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_links).to contain_exactly(target_post_tag)
expect(active.count).to eq(1) expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(active.first.id).to eq(target_post_tag.id) expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded expect(source_tag_name.reload).to have_attributes(
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = source_tag.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).to have_attributes(
name: source_name, aliases: source_alias.name, created_by_user: user)
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'])
expect(target_versions.last.aliases.split).to eq([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 context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } 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 it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag]) described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(Tag.find(target_tag.id)).to be_present expect(Tag.find(target_tag.id)).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_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).to be_present expect(source_tag_name.reload).to have_attributes(
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -260,18 +457,17 @@ RSpec.describe Tag, type: :model do
) )
end 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]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
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)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).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) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -288,34 +484,30 @@ RSpec.describe Tag, type: :model do
message: 'init') message: 'init')
end 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 = earlier_source.tag_name
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect { expect {
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [earlier_source, source_tag])
}.to raise_error(ActiveRecord::RecordInvalid) }.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded expect(earlier_name.reload).to have_attributes(
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id) tag_id: earlier_source.id, language_code: 'ja', primary_flg: true)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
expect(source_tag_name.reload.canonical_id).to be_nil .to be_empty
expect(target_tag.reload.post_count).to eq(0) expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
end expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
end expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
context 'when merging a nico source tag' do expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') } expect(source_tag.reload.post_count).to eq(1)
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') } expect(source_tag_name.reload).to have_attributes(
let!(:source_tag_name_id) { source_tag.tag_name_id } tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
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(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
end end
@@ -333,6 +525,7 @@ RSpec.describe Tag, type: :model do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -364,12 +557,15 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update') expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload)) 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) expect(unaffected_post.reload.post_versions.count).to eq(1)
end end
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 let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post') Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end end
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe VersionRecord, type: :model do RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') } let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') } let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
it 'makes TagVersion read only after create' do it 'makes TagVersion read only after create' do
version = TagVersion.create!( version = TagVersion.create!(
@@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: nico_tag.name, name: "nico:#{ nico_tag.name }",
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: nico_tag.name, name: "nico:#{ nico_tag.name }",
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
+5 -6
ファイルの表示
@@ -898,12 +898,11 @@ RSpec.describe 'Gekanator learning API', type: :request do
describe 'GET /gekanator/questions' do describe 'GET /gekanator/questions' do
it 'omits questions for deprecated tags' do it 'omits questions for deprecated tags' do
active_tag = Tag.create!(name: 'active_question_tag', category: :general) active_tag = create(:tag, name: 'active_question_tag', category: :general)
deprecated_tag = Tag.create!( deprecated_tag = create(:tag,
name: 'deprecated_question_tag', name: 'deprecated_question_tag',
category: :general, category: :general,
deprecated_at: Time.current deprecated_at: Time.current)
)
[active_tag, deprecated_tag].each do |question_tag| [active_tag, deprecated_tag].each do |question_tag|
GekanatorQuestion.create!( GekanatorQuestion.create!(
+5 -6
ファイルの表示
@@ -4,12 +4,11 @@ require 'rails_helper'
RSpec.describe 'Gekanator posts API', type: :request do RSpec.describe 'Gekanator posts API', type: :request do
describe 'GET /gekanator/posts' do describe 'GET /gekanator/posts' do
it 'omits deprecated tags and returns the stored similarity cosine' do it 'omits deprecated tags and returns the stored similarity cosine' do
active_tag = Tag.create!(name: 'active tag', category: :general) active_tag = create(:tag, name: 'active tag', category: :general)
deprecated_tag = Tag.create!( deprecated_tag = create(:tag,
name: 'deprecated tag', name: 'deprecated tag',
category: :general, category: :general,
deprecated_at: Time.current deprecated_at: Time.current)
)
post_record = Post.create!(title: 'source', url: 'https://example.com/source') post_record = Post.create!(title: 'source', url: 'https://example.com/source')
target_post = Post.create!(title: 'target', url: 'https://example.com/target') target_post = Post.create!(title: 'target', url: 'https://example.com/target')
+236
ファイルの表示
@@ -0,0 +1,236 @@
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
expect(tag_name.tag.tag_name_id).to eq(tag_name.id)
tag_name.tag
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 Japanese fallback' do
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 Japanese fallback' do
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
# 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
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
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
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
tag = create(:tag)
put "/tags/#{ tag.id }", params: locale_params.merge(
name: tag.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, tag_name: english_name, category: :material) }
let!(:japanese_tag) do
create(:tag, name: 'temporary_japanese', category: :general).tap do |tag|
# Isolate lookup from the separately tested uniqueness validation.
tag.tag_name.update_columns(name: 'shared_name')
end
end
it 'plans and creates the post using the English identity and category' do
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(japanese_tag)
expect(english_tag.reload.category).to eq('material')
expect(japanese_tag.reload.category).to eq('general')
end
it 'updates the post using the English identity' do
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(japanese_tag)
end
it 'resolves the English material tag' do
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: japanese_tag)).to be_empty
end
it 'resolves the English parent tag' do
child = create(:tag)
put "/tags/#{ child.id }", params: {
locale: 'en', name: child.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
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, tag_name: english_name) }
it 'allows a rename to a name already used in Japanese' do
japanese = create(:tag, name: 'rename_target')
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body
expect(tag.reload.tag_name).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id)
expect(japanese.reload.tag_name).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: japanese.id)
end
it 'creates aliases in the requested language' do
put "/tags/#{ tag.id }", params: {
locale: 'en', name: tag.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')
expect(tag.reload.tag_name_id).to eq(english_name.id)
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
japanese = create(:tag, name: 'japanese_owner')
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.id,
primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: {
locale: 'en', name: tag.name, category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.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', japanese.id], ['en', tag.id])
end
end
end
+17 -17
ファイルの表示
@@ -26,10 +26,10 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials' do describe 'GET /materials' do
let!(:tag_a) do let!(:tag_a) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) create(:tag, name: 'material_index_a', category: :material)
end end
let!(:tag_b) do let!(:tag_b) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) create(:tag, name: 'material_index_b', category: :material)
end end
let!(:material_a) do 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 it 'filters by descendant tags and returns stable parent tag groups' do
root = root =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'), create(:tag, name: 'material_scope_root',
category: :material) category: :material)
child_b = child_b =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'), create(:tag, name: 'material_scope_b',
category: :material) category: :material)
child_a = child_a =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'), create(:tag, name: 'material_scope_a',
category: :material) category: :material)
deprecated = deprecated =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'), create(:tag, name: 'material_scope_old',
category: :material, category: :material,
deprecated_at: Time.current) deprecated_at: Time.current)
grandchild = grandchild =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'), create(:tag, name: 'material_scope_grandchild',
category: :material) category: :material)
root_material = root_material =
build_material(tag: root, user: member_user, build_material(tag: root, user: member_user,
@@ -179,7 +179,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
let!(:tag) do let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) create(:tag, name: 'material_show', category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
@@ -323,8 +323,8 @@ RSpec.describe 'Materials API', type: :request do
end end
it 'returns 422 when the existing tag is not material/character' do it 'returns 422 when the existing tag is not material/character' do
general_tag_name = TagName.create!(name: 'material_create_general_tag') general_tag_name = create(:tag_name, name: 'material_create_general_tag')
Tag.create!(tag_name: general_tag_name, category: :general) create(:tag, tag_name: general_tag_name, category: :general)
post '/materials', params: { post '/materials', params: {
tag: 'material_create_general_tag', tag: 'material_create_general_tag',
@@ -384,7 +384,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'PUT /materials/:id' do describe 'PUT /materials/:id' do
let!(:tag) do let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) create(:tag, name: 'material_update_old', category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
@@ -508,7 +508,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing material tag without history' do it 'backfills a create tag_version for an existing material tag without history' do
existing_tag = existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'), create(:tag, name: 'material_update_existing_no_history',
category: :material) category: :material)
expect(existing_tag.tag_versions).to be_empty expect(existing_tag.tag_versions).to be_empty
@@ -531,7 +531,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing character tag without history' do it 'backfills a create tag_version for an existing character tag without history' do
existing_tag = existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'), create(:tag, name: 'material_update_character_no_history',
category: :character) category: :character)
expect(existing_tag.tag_versions).to be_empty expect(existing_tag.tag_versions).to be_empty
@@ -651,8 +651,8 @@ RSpec.describe 'Materials API', type: :request do
end end
describe 'GET /materials/download.zip' do describe 'GET /materials/download.zip' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } let!(:tag_a) { create(:tag, name: 'zip_a', category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } let!(:tag_b) { create(:tag, name: 'zip_b', category: :material) }
let!(:material_a) do let!(:material_a) do
build_material(tag: tag_a, user: member_user, build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a')) file: dummy_upload(filename: 'a.png', body: 'zip-a'))
@@ -701,7 +701,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/versions' do describe 'GET /materials/versions' do
let!(:tag) do let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) create(:tag, name: 'material_history', category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
@@ -776,7 +776,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'DELETE /materials/:id' do describe 'DELETE /materials/:id' do
let!(:tag) do let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) create(:tag, name: 'material_destroy', category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
+106 -41
ファイルの表示
@@ -3,10 +3,34 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do it 'returns the legacy Tag-compatible external fields' do
create_list(:tag, 3, :nico) 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(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(1)
@@ -14,12 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico) linked = create(:external_tag)
linked.tag_name.update!(name: 'nico:search_linked') linked.update!(name: 'search_linked')
unlinked = create(:tag, :nico) unlinked = create(:external_tag)
unlinked.tag_name.update!(name: 'nico:search_unlinked') unlinked.update!(name: 'search_unlinked')
other = create(:tag, :nico) other = create(:external_tag)
other.tag_name.update!(name: 'nico:other') other.update!(name: 'other')
destination = create(:tag, :general) destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search') destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -41,37 +65,77 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end 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 it 'sorts by name and timestamps' do
older = create(:tag, :nico) older = create(:external_tag)
older.tag_name.update!(name: 'nico:a') older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico) newer = create(:external_tag)
newer.tag_name.update!(name: 'nico:b') newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = 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) older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag = 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) 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]) 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]) 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(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'))) 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) .to be_within(1.second).of(older_post_tag.created_at)
end end
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 describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) } let(:nico_tag) { create(:external_tag) }
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -85,26 +149,27 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end 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) sign_in_as(member)
non_nico = create(:tag, :general) non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } 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 end
it '200 and updates linked tags while recording tag versions' do it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin) sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a') linked_a_name = create(:tag_name, name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general) linked_a = create(:tag, tag_name: linked_a_name, category: :general)
linked_b_name = TagName.create!(name: 'nico_linked_b') linked_b_name = create(:tag_name, name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general) linked_b = create(:tag, 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 { expect {
patch "/tags/nico/#{nico_tag.id}", params: { patch "/tags/nico/#{nico_tag.id}", params: {
@@ -126,26 +191,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([ expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a', 'nico_linked_a',
'nico_linked_b' 'nico_linked_b'])
])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end 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) sign_in_as(member)
linked = create(:tag)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name) NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: member)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect { expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
}.not_to change(NicoTagVersion, :count) }.to change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:ok)
expect(json.fetch('errors')).to include( expect(json).to eq([])
'tags' => ['ニコニコ・タグ同士は連携できません.']) 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 end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
-184
ファイルの表示
@@ -1,184 +0,0 @@
require 'rails_helper'
RSpec.describe 'Post imports API', type: :request do
let(:member) { create(:user, :member) }
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
tags: ''
)
end
describe 'POST /posts/import/preview' do
it 'requires a member' do
sign_out
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:unauthorized)
sign_in_as(create(:user, :guest))
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:forbidden)
end
it 'parses a URL list and returns preview rows' do
sign_in_as(member)
post '/posts/import/preview', params: {
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
'https://example.com/one',
'https://example.com/two'
])
end
it 'returns a safe 400 response for an invalid source' do
sign_in_as(member)
post '/posts/import/preview', params: { source: '' }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('URL を入力してください.')
end
end
describe 'POST /posts/import/validate' do
it 'accepts camel-case row properties and returns their warnings' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'manual title' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { title: ['old warning'] },
baseWarnings: ['base warning']
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
result = json.fetch('rows').first
expect(result.fetch('source_row')).to eq(1)
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
expect(result.fetch('base_warnings')).to eq(['base warning'])
end
it 'rejects a non-array rows value with 400' do
sign_in_as(member)
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end
it 'returns original created datetime validation errors for minute precision' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: 1,
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: {
originalCreatedFrom: '2020-01-01T00:00:30Z',
originalCreatedBefore: '2020-01-01T00:01Z' },
provenance: {
url: 'manual',
originalCreatedFrom: 'manual',
originalCreatedBefore: 'manual' },
tagSources: { automatic: '', manual: '' }
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('rows').first.fetch('validation_errors'))
.not_to have_key('original_created_at')
end
end
describe 'POST /posts/import' do
it 'keeps the duration string contract through preview, validate, and import' do
sign_in_as(member)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
duration: '2.5',
tags: '動画'
)
post '/posts/import/preview', params: {
source: 'https://example.com/video'
}
preview_row = json.fetch('rows').first
expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import/validate', params: {
rows: [{
sourceRow: preview_row.fetch('source_row'),
url: preview_row.fetch('url'),
attributes: preview_row.fetch('attributes'),
provenance: preview_row.fetch('provenance'),
tagSources: preview_row.fetch('tag_sources'),
metadataUrl: preview_row.fetch('metadata_url')
}],
changed_row: -1
}
validated_row = json.fetch('rows').first
expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import', params: {
rows: [{
sourceRow: validated_row.fetch('source_row'),
url: validated_row.fetch('url'),
attributes: validated_row.fetch('attributes'),
provenance: validated_row.fetch('provenance'),
tagSources: validated_row.fetch('tag_sources'),
metadataUrl: validated_row.fetch('metadata_url')
}]
}
expect(response).to have_http_status(:ok)
expect(Post.order(:id).last.video_ms).to eq(2_500)
end
it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member)
post '/posts/import', params: {
rows: [{
sourceRow: 1,
url: existing.url,
attributes: { title: 'ignored' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' }
}]
}
expect(response).to have_http_status(:ok)
expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0)
expect(json.fetch('rows').first).to include(
'status' => 'skipped',
'existing_post_id' => existing.id
)
end
end
end
+621 -208
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
-58
ファイルの表示
@@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
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 end
describe "DELETE /tag_children" do describe "DELETE /tag_children" do
@@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
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
end end
+2 -2
ファイルの表示
@@ -232,8 +232,8 @@ RSpec.describe 'TagVersions API', type: :request do
end end
it 'does not create tag versions by wiki updates when tag has no versions yet' do 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_name = create(:tag_name, name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) wiki_tag = create(:tag, tag_name: wiki_tag_name, category: :general)
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
+2 -2
ファイルの表示
@@ -8,8 +8,8 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
def create_tag! name:, category: :general def create_tag! name:, category: :general
tag_name = TagName.create!(name:) tag_name = create(:tag_name, name:)
Tag.create!(tag_name:, category:) create(:tag, tag_name:, category:)
end end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
+83
ファイルの表示
@@ -226,6 +226,14 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2], [platform2, code2],
) )
end end
it 'locks the tag before replacing the complete list' do
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
do_request
expect(response).to have_http_status(:ok)
end
end end
context 'when tag already has deerjikists' do context 'when tag already has deerjikists' do
@@ -299,6 +307,81 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
end end
context 'when platform is outside the enum' do
let(:payload) do
[
{ platform: 'invalid', code: code1 },
]
end
it 'returns 422 with an indexed platform error without changing the list' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
end
end
context 'when a requested deerjikist belongs to another tag' do
let!(:other_tag) { create(:tag, category: :deerjikist) }
let!(:owned_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let!(:conflicting_deerjikist) do
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
end
let(:payload) do
[
{ platform: 'nico', code: 'new-code' },
{ platform: platform2, code: code2 },
]
end
before do
other_tag.tag_name.update!(name: 'existing-deerjikist')
end
it 'returns an indexed 422 error and rolls back the complete replacement' do
expect {
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
end
end
context 'when a requested deerjikist already belongs to the same tag' do
let!(:existing_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let(:payload) do
[
{ platform: platform1, code: code1 },
]
end
it 'keeps the existing row' do
expect {
do_request
}.not_to change { existing_deerjikist.reload.created_at }
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
.to eq([[platform1, code1]])
end
end
context 'when youtube code is handle' do context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
+388 -227
ファイルの表示
@@ -2,13 +2,13 @@ require 'cgi'
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tags API', type: :request do RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tn) { create(:tag_name, name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: :general) } let!(:tag) { create(:tag, tag_name: tn, category: :general) }
let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) } let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) }
let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') } let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') }
let!(:post_tag) { PostTag.create!(post:, tag:) } let!(:post_tag) { PostTag.create!(post:, tag:) }
let!(:tn2) { TagName.create!(name: 'unknown') } let!(:tn2) { create(:tag_name, name: 'unknown') }
let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } let!(:tag2) { create(:tag, tag_name: tn2, category: :general) }
def response_tags def response_tags
json.fetch('tags') json.fetch('tags')
@@ -30,6 +30,30 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags' do describe 'GET /tags' do
it 'includes legacy external JSON alongside an internal tag with the same id' do
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
get '/tags', params: { name: 'spec_' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(response_tags).to contain_exactly(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'),
a_hash_including(
'id' => external.id,
'name' => 'nico:spec_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))
end
it 'returns tags with count and metadata' do it 'returns tags with count and metadata' do
get '/tags' get '/tags'
@@ -67,7 +91,7 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'filters tags by category' do it 'filters tags by category' do
meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme) meme = create(:tag, name: 'meme_only', category: :meme)
get '/tags', params: { category: 'meme' } get '/tags', params: { category: 'meme' }
@@ -77,12 +101,11 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'filters tags by deprecated state' do it 'filters tags by deprecated state' do
deprecated_tag = Tag.create!( deprecated_tag = create(:tag,
name: 'deprecated_filter', name: 'deprecated_filter',
category: :general, category: :general,
deprecated_at: 1.day.from_now deprecated_at: 1.day.from_now)
) active_tag = create(:tag, name: 'active_filter', category: :general)
active_tag = Tag.create!(name: 'active_filter', category: :general)
get '/tags', params: { name: '_filter', deprecated: '1' } get '/tags', params: { name: '_filter', deprecated: '1' }
@@ -98,9 +121,9 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'filters tags by post_count range' do it 'filters tags by post_count range' do
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) low = create(:tag, name: 'pc_low', category: :general)
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) mid = create(:tag, name: 'pc_mid', category: :general)
high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general) high = create(:tag, name: 'pc_high', category: :general)
low.update_columns(post_count: 1) low.update_columns(post_count: 1)
mid.update_columns(post_count: 3) mid.update_columns(post_count: 3)
@@ -118,8 +141,8 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'filters tags by created_at range' do it 'filters tags by created_at range' do
old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general) old_tag = create(:tag, name: 'created_old', category: :general)
new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general) new_tag = create(:tag, name: 'created_new', category: :general)
old_time = Time.zone.local(2024, 1, 1, 0, 0, 0) old_time = Time.zone.local(2024, 1, 1, 0, 0, 0)
new_time = Time.zone.local(2024, 2, 1, 0, 0, 0) new_time = Time.zone.local(2024, 2, 1, 0, 0, 0)
@@ -138,8 +161,8 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'filters tags by updated_at range' do it 'filters tags by updated_at range' do
old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general) old_tag = create(:tag, name: 'updated_old', category: :general)
new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general) new_tag = create(:tag, name: 'updated_new', category: :general)
old_time = Time.zone.local(2024, 3, 1, 0, 0, 0) old_time = Time.zone.local(2024, 3, 1, 0, 0, 0)
new_time = Time.zone.local(2024, 4, 1, 0, 0, 0) new_time = Time.zone.local(2024, 4, 1, 0, 0, 0)
@@ -158,31 +181,90 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'orders tags by custom category order' do it 'orders tags by custom category order' do
Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist) create(:tag, name: 'cat_deerjikist', category: :deerjikist)
Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme) create(:tag, name: 'cat_meme', category: :meme)
Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character) create(:tag, name: 'cat_character', category: :character)
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) create(:tag, name: 'cat_general', category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) create(:tag, name: 'cat_material', category: :material)
Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) create(:tag, name: 'cat_meta', category: :meta)
Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico) create(:external_tag, name: 'cat_nico')
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response_names).to eq(%w[ expect(response_names).to eq([
cat_deerjikist 'cat_deerjikist', 'cat_meme', 'cat_character',
cat_meme 'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico'])
cat_character expect(json.fetch('count')).to eq(7)
cat_general end
cat_material
cat_meta context 'with mixed legacy pagination' do
nico:cat_nico let!(:first_tag) do
]) create(:tag,
tag_name: create(:tag_name, name: 'a_mixed_page'),
category: :meme)
end
let!(:middle_tag) do
create(:tag,
tag_name: create(:tag_name, name: 'm_mixed_page'),
category: :meta)
end
let!(:last_tag) do
create(:tag,
tag_name: create(:tag_name, name: 'z_mixed_page'),
category: :general)
end
let!(:first_external) do
create(:external_tag, id: first_tag.id, name: 'a_mixed_page')
end
let!(:last_external) do
create(:external_tag, id: last_tag.id, name: 'z_mixed_page')
end
let(:name_order) do
[
[first_tag.id, 'a_mixed_page'], [middle_tag.id, 'm_mixed_page'],
[first_external.id, 'nico:a_mixed_page'], [last_external.id, 'nico:z_mixed_page'],
[last_tag.id, 'z_mixed_page']]
end
let(:category_order) do
[
[first_tag.id, 'a_mixed_page'], [last_tag.id, 'z_mixed_page'],
[middle_tag.id, 'm_mixed_page'], [first_external.id, 'nico:a_mixed_page'],
[last_external.id, 'nico:z_mixed_page']]
end
['name', 'category'].each do |order|
['asc', 'desc'].each do |direction|
it "orders the combined records by #{ order }:#{ direction } before paging" do
ascending = order == 'name' ? name_order : category_order
expected = direction == 'asc' ? ascending : ascending.reverse
[2, 3].each do |limit|
pages = expected.each_slice(limit).to_a + [[]]
pages.each.with_index(1) do |expected_page, page|
get '/tags', params: {
name: 'mixed_page', order: "#{ order }:#{ direction }", page:, limit: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(5)
expect(response_tags.size).to eq(expected_page.size)
expect(response_tags.size).to be <= limit
expect(response_tags.map { [_1.fetch('id'), _1.fetch('name')] })
.to eq(expected_page)
end
end
end
end
end
end end
it 'paginates and keeps total count' do it 'paginates and keeps total count' do
%w[pag_a pag_b pag_c].each do |name| %w[pag_a pag_b pag_c].each do |name|
Tag.create!(tag_name: TagName.create!(name:), category: :general) create(:tag, tag_name: create(:tag_name, name:), category: :general)
end end
get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 }
@@ -193,8 +275,8 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'falls back to default ordering when order is invalid' do it 'falls back to default ordering when order is invalid' do
low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general) low = create(:tag, name: 'fallback_low', category: :general)
high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general) high = create(:tag, name: 'fallback_high', category: :general)
low.update_columns(post_count: 1) low.update_columns(post_count: 1)
high.update_columns(post_count: 9) high.update_columns(post_count: 9)
@@ -207,7 +289,7 @@ RSpec.describe 'Tags API', type: :request do
it 'normalises invalid page and limit' do it 'normalises invalid page and limit' do
%w[norm_a norm_b].each do |name| %w[norm_a norm_b].each do |name|
Tag.create!(tag_name: TagName.create!(name:), category: :general) create(:tag, tag_name: create(:tag_name, name:), category: :general)
end end
get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 }
@@ -219,10 +301,9 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'returns aliases and parent tags' do it 'returns aliases and parent tags' do
parent_tag = Tag.create!( parent_tag = create(:tag,
tag_name: TagName.create!(name: 'index_parent_tag'), tag_name: create(:tag_name, name: 'index_parent_tag'),
category: :meme category: :meme)
)
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
get '/tags', params: { name: 'spec_tag' } get '/tags', params: { name: 'spec_tag' }
@@ -266,10 +347,9 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'returns aliases and parent tags' do it 'returns aliases and parent tags' do
parent_tag = Tag.create!( parent_tag = create(:tag,
tag_name: TagName.create!(name: 'show_parent_tag'), tag_name: create(:tag_name, name: 'show_parent_tag'),
category: :character category: :character)
)
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
request request
@@ -296,9 +376,127 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
it 'falls back to ExternalTag when no internal Tag has the id' do
external = create(
:external_tag,
id: Tag.maximum(:id).to_i + 10_000,
name: 'legacy_id_lookup')
get "/tags/#{ external.id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:legacy_id_lookup',
'category' => 'nico')
end
it 'prefers Tag when Tag and ExternalTag have the same id' do
internal = create(
:tag,
tag_name: create(:tag_name, name: 'internal_collision'))
create(
:external_tag,
id: internal.id,
name: 'external_collision')
get "/tags/#{ internal.id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => internal.id,
'name' => 'internal_collision',
'category' => internal.category)
end
end end
describe 'GET /tags/autocomplete' do describe 'GET /tags/autocomplete' do
it 'combines internal and external matches without conflating equal ids' do
internal = create(:tag, category: :general, name: 'mixed_internal', post_count: 2)
external = create(:external_tag, id: internal.id,
name: 'mixed_external', post_count: 3)
get '/tags/autocomplete', params: { q: 'not:mixed' }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('name') })
.to eq(['nico:mixed_external', 'mixed_internal'])
expect(json.first).to include(
'id' => external.id, 'category' => 'nico', 'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil, 'matched_alias' => nil,
'aliases' => [], 'parents' => [], 'has_wiki' => false,
'material_id' => nil, 'has_deerjikists' => false)
end
it 'matches an external tag by its platform prefix' do
create(:external_tag, name: 'prefix_match', post_count: 1)
get '/tags/autocomplete', params: { q: 'nico:prefix' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:prefix_match'])
end
it 'excludes unused external tags unless present is false' do
create(:external_tag, name: 'unused_external')
get '/tags/autocomplete', params: { q: 'unused' }
expect(json).to be_empty
get '/tags/autocomplete', params: { q: 'unused', present: '0' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:unused_external'])
end
it 'limits the combined results to 20 and sorts ties by displayed name' do
11.times do |i|
name = "combined_#{ i.to_s.rjust(2, '0') }"
create(:tag, category: :general, name:, post_count: 1)
create(:external_tag, name:, post_count: 1)
end
get '/tags/autocomplete', params: { q: 'combined' }
expected = 11.times.map { |i| "combined_#{ i.to_s.rjust(2, '0') }" }
expected += expected.map { |name| "nico:#{ name }" }
expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20))
end
it 'excludes external tags but preserves internal alias matches when nico is false' do
internal = create(:tag, category: :general, name: 'switch_internal', post_count: 1)
alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1)
create(:tag_name, :alias, name: 'switch_alias', tag: alias_target)
create(:external_tag, name: 'switch_external', post_count: 1)
get '/tags/autocomplete', params: { q: 'switch', nico: '0' }
expect(response).to have_http_status(:ok)
expect(json).to contain_exactly(
a_hash_including('id' => internal.id, 'name' => internal.name),
a_hash_including('id' => alias_target.id, 'name' => alias_target.name,
'matched_alias' => 'switch_alias'))
end
['%', '_'].each do |wildcard|
it "treats #{ wildcard } literally for primary, alias, and external names" do
literal = "literal#{ wildcard }match"
create(:tag, category: :general, name: literal, post_count: 1)
alias_target = create(
:tag, category: :general, name: 'literal_alias_target', post_count: 1)
create(:tag_name, :alias, name: "#{ literal }_alias",
tag: alias_target)
create(:external_tag, name: literal, post_count: 1)
create(:tag, category: :general, name: 'literalXmatch', post_count: 2)
create(:external_tag, name: 'literalXmatch', post_count: 2)
get '/tags/autocomplete', params: { q: "literal#{ wildcard }" }
expect(json.map { |row| row.fetch('name') })
.to contain_exactly(literal, alias_target.name, "nico:#{ literal }")
end
end
it 'returns matching tags by q' do it 'returns matching tags by q' do
get '/tags/autocomplete', params: { q: 'spec' } get '/tags/autocomplete', params: { q: 'spec' }
@@ -311,7 +509,7 @@ RSpec.describe 'Tags API', type: :request do
expect(t['matched_alias']).to be(nil) expect(t['matched_alias']).to be(nil)
end end
it 'returns matching canonical tags by q with aliases' do it 'returns matching primary tag names by q with aliases' do
get '/tags/autocomplete', params: { q: 'unk' } get '/tags/autocomplete', params: { q: 'unk' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -324,11 +522,10 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'omits deprecated tags' do it 'omits deprecated tags' do
deprecated_tag = Tag.create!( deprecated_tag = create(:tag,
name: 'spec_deprecated', name: 'spec_deprecated',
category: :general, category: :general,
deprecated_at: Time.current deprecated_at: Time.current)
)
deprecated_tag.update_columns(post_count: 1) deprecated_tag.update_columns(post_count: 1)
get '/tags/autocomplete', params: { q: 'spec_', present: '0' } get '/tags/autocomplete', params: { q: 'spec_', present: '0' }
@@ -340,6 +537,27 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags/name/:name' do describe 'GET /tags/name/:name' do
it 'preserves qualified external name lookup used by wiki pages' do
external = create(:external_tag, name: 'detail_external', post_count: 3)
get "/tags/name/#{ CGI.escape('nico:detail_external') }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:detail_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)
end
it 'returns tag by name' do it 'returns tag by name' do
get "/tags/name/#{ CGI.escape('spec_tag') }" get "/tags/name/#{ CGI.escape('spec_tag') }"
@@ -487,18 +705,6 @@ RSpec.describe 'Tags API', type: :request do
expect(json.fetch('deprecated_at')).to be_present expect(json.fetch('deprecated_at')).to be_present
end end
it 'rejects deprecating a nico tag' do
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.deprecated_at).to be_nil
expect(json.fetch('errors')).to include(
'deprecated' => ['ニコタグは廃止できません.']
)
end
it 'returns 422 when changing normal tag category to nico' do it 'returns 422 when changing normal tag category to nico' do
expect { expect {
patch "/tags/#{tag.id}", params: { category: 'nico' } patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -508,32 +714,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general') expect(tag.reload.category).to eq('general')
end end
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end
it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -592,7 +772,8 @@ RSpec.describe 'Tags API', type: :request do
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name) expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
patch "/tags/#{ tag.id }", params: { name: 'spec_tag' } patch "/tags/#{ tag.id }", params: { name: 'spec_tag' }
@@ -601,8 +782,10 @@ RSpec.describe 'Tags API', type: :request do
tag.reload tag.reload
expect(tag.name).to eq('spec_tag') expect(tag.name).to eq('spec_tag')
expect(tag.tag_name.canonical_id).to be_nil expect(tag.tag_name).to have_attributes(
expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name) tag_id: tag.id, language_code: 'ja', primary_flg: true)
expect(TagName.find_by!(language_code: 'ja', name: 'patch_roundtrip_target'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
end end
it '別 tag の正規名には変更できない' do it '別 tag の正規名には変更できない' do
@@ -621,7 +804,8 @@ RSpec.describe 'Tags API', type: :request do
) )
expect(tag.reload.name).to eq('spec_tag') expect(tag.reload.name).to eq('spec_tag')
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko') expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
.to contain_exactly('unko')
expect(wiki_page.reload.tag_name).to eq(tag.tag_name) expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
end end
@@ -638,27 +822,27 @@ RSpec.describe 'Tags API', type: :request do
describe 'GET /tags/with-depth' do describe 'GET /tags/with-depth' do
let!(:root_meme) do let!(:root_meme) do
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme) create(:tag, name: 'depth_a_root_meme', category: :meme)
end end
let!(:root_material) do let!(:root_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material) create(:tag, name: 'depth_b_root_material', category: :material)
end end
let!(:hidden_general_root) do let!(:hidden_general_root) do
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general) create(:tag, name: 'depth_hidden_general_root', category: :general)
end end
let!(:child_character) do let!(:child_character) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character) create(:tag, name: 'depth_child_character', category: :character)
end end
let!(:grandchild_material) do let!(:grandchild_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material) create(:tag, name: 'depth_grandchild_material', category: :material)
end end
let!(:child_general) do let!(:child_general) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general) create(:tag, name: 'depth_child_general', category: :general)
end end
before do before do
@@ -701,15 +885,13 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'passes through deprecated tags when finding children' do it 'passes through deprecated tags when finding children' do
deprecated_middle = Tag.create!( deprecated_middle = create(:tag,
name: 'depth_deprecated_middle', name: 'depth_deprecated_middle',
category: :character, category: :character,
deprecated_at: Time.current deprecated_at: Time.current)
) visible_descendant = create(:tag,
visible_descendant = Tag.create!( name: 'depth_visible_descendant',
name: 'depth_visible_descendant', category: :material)
category: :material
)
TagImplication.create!(parent_tag: root_material, tag: deprecated_middle) TagImplication.create!(parent_tag: root_material, tag: deprecated_middle)
TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant) TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant)
@@ -721,24 +903,20 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'passes through multiple deprecated tags for roots and has_children' do it 'passes through multiple deprecated tags for roots and has_children' do
active_child = Tag.create!( active_child = create(:tag,
name: 'depth_active_child_below_deprecated', name: 'depth_active_child_below_deprecated',
category: :character category: :character)
) deprecated_parent = create(:tag,
deprecated_parent = Tag.create!( name: 'depth_deprecated_parent',
name: 'depth_deprecated_parent', category: :character,
category: :character, deprecated_at: Time.current)
deprecated_at: Time.current deprecated_grandparent = create(:tag,
) name: 'depth_deprecated_grandparent',
deprecated_grandparent = Tag.create!( category: :material,
name: 'depth_deprecated_grandparent', deprecated_at: Time.current)
category: :material, active_ancestor = create(:tag,
deprecated_at: Time.current name: 'depth_active_ancestor',
) category: :meme)
active_ancestor = Tag.create!(
name: 'depth_active_ancestor',
category: :meme
)
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
@@ -763,15 +941,13 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'treats an active tag with only deprecated ancestors as a root' do it 'treats an active tag with only deprecated ancestors as a root' do
active_child = Tag.create!( active_child = create(:tag,
name: 'depth_root_below_deprecated', name: 'depth_root_below_deprecated',
category: :character category: :character)
) deprecated_parent = create(:tag,
deprecated_parent = Tag.create!( name: 'depth_root_deprecated_parent',
name: 'depth_root_deprecated_parent', category: :material,
category: :material, deprecated_at: Time.current)
deprecated_at: Time.current
)
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
get '/tags/with-depth' get '/tags/with-depth'
@@ -785,16 +961,14 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'terminates when deprecated implications contain a cycle' do it 'terminates when deprecated implications contain a cycle' do
first = Tag.create!( first = create(:tag,
name: 'depth_cycle_first', name: 'depth_cycle_first',
category: :character, category: :character,
deprecated_at: Time.current deprecated_at: Time.current)
) second = create(:tag,
second = Tag.create!( name: 'depth_cycle_second',
name: 'depth_cycle_second', category: :material,
category: :material, deprecated_at: Time.current)
deprecated_at: Time.current
)
TagImplication.create!(tag: first, parent_tag: root_material) TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first) TagImplication.create!(tag: second, parent_tag: first)
now = Time.current now = Time.current
@@ -820,19 +994,19 @@ RSpec.describe 'Tags API', type: :request do
let!(:material_user) { create_member_user! } let!(:material_user) { create_member_user! }
let!(:root_tag) do let!(:root_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material) create(:tag, name: 'materials_root', category: :material)
end end
let!(:child_a_tag) do let!(:child_a_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material) create(:tag, name: 'materials_child_a', category: :material)
end end
let!(:child_b_tag) do let!(:child_b_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character) create(:tag, name: 'materials_child_b', category: :character)
end end
let!(:grandchild_tag) do let!(:grandchild_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) create(:tag, name: 'materials_grandchild', category: :material)
end end
let!(:root_material) do let!(:root_material) do
@@ -976,14 +1150,12 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'name, category, aliases, parent tags をまとめて更新できる' do it 'name, category, aliases, parent tags をまとめて更新できる' do
old_parent = Tag.create!( old_parent = create(:tag,
tag_name: TagName.create!(name: 'put_old_parent'), tag_name: create(:tag_name, name: 'put_old_parent'),
category: :general category: :general)
) kept_parent = create(:tag,
kept_parent = Tag.create!( tag_name: create(:tag_name, name: 'put_kept_parent'),
tag_name: TagName.create!(name: 'put_kept_parent'), category: :general)
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent) TagImplication.create!(tag:, parent_tag: old_parent)
TagImplication.create!(tag:, parent_tag: kept_parent) TagImplication.create!(tag:, parent_tag: kept_parent)
@@ -1002,14 +1174,17 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.name).to eq('put_renamed_tag') expect(tag.name).to eq('put_renamed_tag')
expect(tag.category).to eq('meme') expect(tag.category).to eq('meme')
expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_a'))
expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) .to have_attributes(tag_id: tag.id, primary_flg: false)
expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_b'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
old_name_alias = TagName.find_by(name: 'spec_tag') old_name_alias = TagName.find_by(name: 'spec_tag')
expect(old_name_alias).to be_present expect(old_name_alias).to be_present
expect(old_name_alias.canonical).to eq(tag.tag_name) expect(old_name_alias).to have_attributes(
tag_id: tag.id, language_code: 'ja', primary_flg: false)
expect(alias_tn.reload.canonical).to be_nil expect(alias_tn.reload.tag_id).to be_nil
expect(tag.parents.map(&:name)).to contain_exactly( expect(tag.parents.map(&:name)).to contain_exactly(
'put_kept_parent', 'put_kept_parent',
@@ -1044,7 +1219,8 @@ RSpec.describe 'Tags API', type: :request do
tag.reload tag.reload
expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_self_test'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
expect(json['aliases']).to include('put_alias_self_test') expect(json['aliases']).to include('put_alias_self_test')
expect(json['aliases']).not_to include('spec_tag') expect(json['aliases']).not_to include('spec_tag')
end end
@@ -1115,10 +1291,14 @@ RSpec.describe 'Tags API', type: :request do
tag.reload tag.reload
expect(tag.name).to eq('spec_tag') expect(tag.name).to eq('spec_tag')
expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name) expect(TagName.find_by!(language_code: 'ja', name: 'put_roundtrip_b'))
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko') .to have_attributes(tag_id: tag.id, primary_flg: false)
expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag') expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
expect(alias_tn.reload.canonical).to eq(tag.tag_name) .to contain_exactly('put_roundtrip_b', 'unko')
expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
.not_to include('spec_tag')
expect(alias_tn.reload).to have_attributes(
tag_id: tag.id, language_code: 'ja', primary_flg: false)
version = tag.tag_versions.order(:version_no).last version = tag.tag_versions.order(:version_no).last
@@ -1128,14 +1308,12 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'parent tag の snapshot も作成する' do it 'parent tag の snapshot も作成する' do
old_parent = Tag.create!( old_parent = create(:tag,
tag_name: TagName.create!(name: 'put_snapshot_old_parent'), tag_name: create(:tag_name, name: 'put_snapshot_old_parent'),
category: :general category: :general)
) new_parent = create(:tag,
new_parent = Tag.create!( tag_name: create(:tag_name, name: 'put_snapshot_new_parent'),
tag_name: TagName.create!(name: 'put_snapshot_new_parent'), category: :general)
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent) TagImplication.create!(tag:, parent_tag: old_parent)
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -1169,28 +1347,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general') expect(tag.category).to eq('general')
end end
it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)
expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
deprecated: '0',
}
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end
it 'system tag の name は変更できない' do it 'system tag の name は変更できない' do
system_tag = Tag.tagme system_tag = Tag.tagme
old_name = system_tag.name old_name = system_tag.name
@@ -1251,7 +1407,7 @@ RSpec.describe 'Tags API', type: :request do
) )
end end
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: tag.tag_name, tag_name: tag.tag_name,
@@ -1285,7 +1441,8 @@ RSpec.describe 'Tags API', type: :request do
expect(wiki_page.reload.tag_name).to eq(tag.tag_name) expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name) expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
versions = wiki_page.wiki_versions.order(:version_no).last(2) versions = wiki_page.wiki_versions.order(:version_no).last(2)
@@ -1294,16 +1451,16 @@ RSpec.describe 'Tags API', type: :request do
end end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
old_owner = Tag.create!( old_owner = create(:tag,
tag_name: TagName.create!(name: 'put_alias_old_owner'), tag_name: create(:tag_name, name: 'put_alias_old_owner'),
category: :general category: :general)
) stolen_alias = create(:tag_name, :alias,
stolen_alias = TagName.create!( name: 'put_stolen_alias',
name: 'put_stolen_alias', tag: old_owner)
canonical: old_owner.tag_name
)
expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') expect(TagName.where(tag_id: old_owner.id, language_code: 'ja',
primary_flg: false).pluck(:name))
.to include('put_stolen_alias')
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -1319,8 +1476,11 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(stolen_alias.reload.canonical).to eq(tag.tag_name) expect(stolen_alias.reload).to have_attributes(
expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias') tag_id: tag.id, language_code: 'ja', primary_flg: false)
expect(TagName.where(tag_id: old_owner.id, language_code: 'ja',
primary_flg: false).pluck(:name))
.not_to include('put_stolen_alias')
old_owner_versions = old_owner.tag_versions.order(:version_no) old_owner_versions = old_owner.tag_versions.order(:version_no)
@@ -1332,14 +1492,12 @@ RSpec.describe 'Tags API', type: :request do
end end
it '別 tag の alias 名を rename で奪へる' do it '別 tag の alias 名を rename で奪へる' do
old_owner = Tag.create!( old_owner = create(:tag,
tag_name: TagName.create!(name: 'put_alias_collision_owner'), tag_name: create(:tag_name, name: 'put_alias_collision_owner'),
category: :general category: :general)
) stolen_alias = create(:tag_name, :alias,
stolen_alias = TagName.create!( name: 'put_alias_collision_name',
name: 'put_alias_collision_name', tag: old_owner)
canonical: old_owner.tag_name
)
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: tag.tag_name, tag_name: tag.tag_name,
@@ -1355,16 +1513,20 @@ RSpec.describe 'Tags API', type: :request do
deprecated: '0', deprecated: '0',
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok), response.body
tag.reload tag.reload
old_owner.reload old_owner.reload
stolen_alias.reload stolen_alias.reload
expect(tag.name).to eq('put_alias_collision_name') expect(tag.name).to eq('put_alias_collision_name')
expect(stolen_alias.canonical_id).to be_nil expect(stolen_alias).to have_attributes(
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name) tag_id: tag.id, language_code: 'ja', primary_flg: true)
expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name') expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
expect(TagName.where(tag_id: old_owner.id, language_code: 'ja',
primary_flg: false).pluck(:name))
.not_to include('put_alias_collision_name')
old_owner_versions = old_owner.tag_versions.order(:version_no) old_owner_versions = old_owner.tag_versions.order(:version_no)
@@ -1374,10 +1536,9 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'parent_tags に指定すると循環する tag は 422 にする' do it 'parent_tags に指定すると循環する tag は 422 にする' do
child = Tag.create!( child = create(:tag,
tag_name: TagName.create!(name: 'put_cycle_child'), tag_name: create(:tag_name, name: 'put_cycle_child'),
category: :general category: :general)
)
TagImplication.create!(tag: child, parent_tag: tag) TagImplication.create!(tag: child, parent_tag: tag)
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
+1 -1
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_conflict_request'), tag_name: create(:tag_name, name: 'wiki_conflict_request'),
body: 'first', body: 'first',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
+1 -1
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
message:) message:)
-37
ファイルの表示
@@ -1,37 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+14 -15
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:tn) { create(:tag_name, name: 'spec_wiki_title') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: tn, tag_name: tn,
@@ -19,11 +19,10 @@ RSpec.describe 'Wiki API', type: :request do
message: 'init') message: 'init')
end end
let!(:tag) do let!(:tag) do
Tag.create!( create(:tag,
tag_name: tn, tag_name: tn,
category: :general, category: :general,
deprecated_at: Time.zone.local(2026, 6, 1) deprecated_at: Time.zone.local(2026, 6, 1))
)
end end
describe 'GET /wiki' do describe 'GET /wiki' do
@@ -208,7 +207,7 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
let!(:test_tag_name) { TagName.create!(name: 'TestPage') } let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -275,8 +274,8 @@ RSpec.describe 'Wiki API', type: :request do
end end
it 'wiki body だけを変更しても tag version は作成しない' do it 'wiki body だけを変更しても tag version は作成しない' do
linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') linked_tag_name = create(:tag_name, name: 'wiki_body_only_tag')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) linked_tag = create(:tag, tag_name: linked_tag_name, category: :general)
TagVersionRecorder.record!( TagVersionRecorder.record!(
tag: linked_tag, tag: linked_tag,
@@ -359,13 +358,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'spec_wiki_title_2'), tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
body: 'search body 2', body: 'search body 2',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'unrelated_title'), tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body', body: 'unrelated body',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
@@ -434,7 +433,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し # 別ページを作って revision 無し
tn2 = TagName.create!(name: 'spec_no_rev') tn2 = create(:tag_name, name: 'spec_no_rev')
# 異常データ: revision 無し WikiPage を直接作る # 異常データ: revision 無し WikiPage を直接作る
p2 = WikiPage.create!( p2 = WikiPage.create!(
tag_name: tn2, tag_name: tn2,
@@ -514,7 +513,7 @@ RSpec.describe 'Wiki API', type: :request do
describe 'Wiki::Commit.redirect!' do describe 'Wiki::Commit.redirect!' do
it 'raises because redirect revisions are deprecated' do it 'raises because redirect revisions are deprecated' do
target_tag_name = TagName.create!(name: 'redirect_deprecated_target') target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target = target =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: target_tag_name, tag_name: target_tag_name,
@@ -535,8 +534,8 @@ RSpec.describe 'Wiki API', type: :request do
end end
it 'wiki title を変更すると対応する tag の version を作成する' do it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) linked_tag = create(:tag, tag_name: linked_tag_name, category: :general)
linked_page = linked_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
+1 -1
ファイルの表示
@@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
def create_wiki_page title:, body: def create_wiki_page title:, body:
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe MaterialSyncImporter do RSpec.describe MaterialSyncImporter do
let(:user) { create(:user, :member) } let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) } let(:tag) { create(:tag, name: 'sync_tag', category: :material) }
def tempfile_for body def tempfile_for body
Tempfile.new(['material-sync-importer', '.png']).tap do |file| Tempfile.new(['material-sync-importer', '.png']).tap do |file|
+76
ファイルの表示
@@ -0,0 +1,76 @@
require 'rails_helper'
RSpec.describe NicoTagVersionRecorder do
let(:external_tag) { create(:external_tag, name: 'raw tag[]') }
let(:member) { create(:user, :member) }
def record event_type
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and platform-qualified name' do
version = record(:create)
expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end
it 'uses the latest history number when the record has no version_no column' do
first = record(:create)
external_tag.update!(name: 'changed')
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
it 'builds the qualified name from the registered platform value' do
external = create(:external_tag, name: 'foo')
locked_scope = instance_double(ActiveRecord::Relation)
allow(ExternalTag).to receive(:unscoped).and_return(locked_scope)
allow(locked_scope).to receive(:lock).and_return(locked_scope)
allow(locked_scope).to receive(:find).with(external.id).and_return(external)
allow(external).to receive(:platform).and_return('registered_external')
version = described_class.record!(
external_tag: external, event_type: :create, created_by_user: member)
expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member)
end
it 'returns the latest version without appending an unchanged snapshot' do
first = record(:create)
expect { expect(record(:update)).to eq(first) }
.not_to change(NicoTagVersion, :count)
end
it 'still requires a create event before any update' do
expect { record(:update) }
.to raise_error(RuntimeError, 'NicoTagVersion first event must be create')
expect(external_tag.nico_tag_versions).to be_empty
end
it 'still rejects a second create event' do
first = record(:create)
expect { record(:create) }
.to raise_error(RuntimeError, 'NicoTagVersion create event already exists')
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
tags = ['z_link', 'a_link'].map { |name| create(:tag, name:, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
end
end
+68
ファイルの表示
@@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe PostBulkCreator do
include_context 'English locale'
it 'limits workers to two and keeps failures in their request slots' do
actor = instance_double(User, id: 123)
allow(User).to receive(:find).with(123) {
instance_double(User, id: 123)
}
mutex = Mutex.new
active = 0
maximum_active = 0
creators = []
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
preflight = instance_double(PostCreatePreflight)
allow(preflight).to receive(:run) do
mutex.synchronize do
active += 1
maximum_active = [maximum_active, active].max
end
sleep 0.02
mutex.synchronize { active -= 1 }
attributes.symbolize_keys.merge(
existing_post_id: nil,
field_warnings: { },
base_warnings: [])
end
preflight
end
allow(PostCreator).to receive(:new) do |attributes:, **|
creator = instance_double(PostCreator)
mutex.synchronize { creators << creator }
if attributes[:title] == 'broken'
allow(creator).to receive(:create!).and_raise(StandardError, 'broken')
else
post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i)
allow(creator).to receive(:create!).and_return(post)
end
creator
end
posts = [
{ 'title' => 'post 1', 'url' => 'https://example.com/1' },
{ 'title' => 'broken', 'url' => 'https://example.com/2' },
{ 'title' => 'post 3', 'url' => 'https://example.com/3' },
{ 'title' => 'post 4', 'url' => 'https://example.com/4' }]
results = described_class.new(
actor:,
posts:,
thumbnails: { }).run(locale).fetch(:results)
expect(creators.length).to eq(posts.length)
creators.each do |creator|
expect(creator).to have_received(:create!).with(locale).once
end
expect(maximum_active).to eq(2)
expect(results.length).to eq(posts.length)
expect(results.map { _1[:status] }).to eq(
['created', 'failed', 'created', 'created'])
expect(results[0].dig(:post, :id)).to eq(1)
expect(results[1]).to include(status: 'failed', recoverable: false)
expect(results[2].dig(:post, :id)).to eq(3)
expect(results[3].dig(:post, :id)).to eq(4)
end
end
+144
ファイルの表示
@@ -0,0 +1,144 @@
require 'rails_helper'
RSpec.describe PostCreatePlan do
def create_tag! name, category
create(:tag, name:, category:)
end
before do
create_tag!('タグ希望', :meta)
create_tag!('ニジラー情報不詳', :meta)
end
context 'with an explicit locale' do
include_context 'English locale'
it 'resolves the matching language identity and its parents' do
english_name = create(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
english = create(:tag, tag_name: english_name, category: :character)
japanese = create(:tag, name: 'temporary_japanese', category: :general)
# The DB permits this identity; validation has its own contract spec.
japanese.tag_name.update_columns(name: 'shared_name')
english_parent = create(:tag, name: 'english_parent', category: :material)
japanese_parent = create(:tag, name: 'japanese_parent', category: :general)
TagImplication.create!(tag: english, parent_tag: english_parent)
TagImplication.create!(tag: japanese, parent_tag: japanese_parent)
plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale)
expect(plan[:direct_tag_specs]).to eq(
[{ name: 'shared_name', category: :character }])
expect(plan[:post_tag_specs]).to include(
{ name: english_parent.name, category: :material })
expect(plan[:post_tag_specs].pluck(:name)).not_to include(japanese_parent.name)
end
it 'resolves aliases within the requested language' do
japanese = create(:tag, name: 'japanese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, tag_name: english_name, category: :character)
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.id,
primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
expect(plan[:direct_tag_specs]).to eq(
[{ name: english.name, category: :character }])
end
end
it 'plans direct and existing default tags without persisting records' do
counts = [TagName.count, Tag.count]
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'character:new_character',
parent_post_ids: '' }).build!(Locale.nipponese)
expect(plan[:tags]).to eq('new_character')
expect(plan[:direct_tag_specs]).to eq(
[{ name: 'new_character', category: :character }])
expect(plan[:default_tag_specs]).to include(
{ name: 'タグ希望', category: :meta },
{ name: 'ニジラー情報不詳', category: :meta })
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'resolves aliases and keeps tag sections separate from primary names' do
tag = create_tag!('虹夏', :character)
create(:tag_name, :alias, name: 'にじか', tag:)
create_tag!('動画', :meta)
plan = described_class.new(
attributes: {
url: 'https://example.com/video',
title: 'video',
tags: '動画 にじか[0:10-0:20]',
duration: '1:00',
parent_post_ids: '' }).build!(Locale.nipponese)
expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]')
expect(plan[:display_tags]).to include(
{ name: '虹夏',
category: 'character',
section_literals: ['[0:10-0:20]'] })
expect(plan[:video_ms]).to eq(60_000)
end
it 'rejects direct external tag input without creating internal records' do
create(:external_tag, name: 'reserved')
counts = [Tag.count, TagName.count, ExternalTag.count]
expect {
described_class.new(attributes: { tags: 'NiCo:reserved' }).build!(Locale.nipponese)
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).to be_present
}
expect([Tag.count, TagName.count, ExternalTag.count]).to eq(counts)
end
it 'validates a new tag name without persisting it' do
long_name = 'a' * 256
counts = [TagName.count, Tag.count]
expect {
described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: long_name,
parent_post_ids: '' }).build!(Locale.nipponese)
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).not_to be_empty
}
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'ignores duration when the planned tags do not include video' do
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'ordinary_tag',
duration: 'invalid',
parent_post_ids: '' }).build!(Locale.nipponese)
expect(plan[:duration]).to eq('invalid')
expect(plan[:video_ms]).to be_nil
end
end

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