コミットを比較

..
14 コミット
作成者 SHA1 メッセージ 日付
みてるぞ d1bab299ab #419 フロントのテスト追加 2026-09-23 18:22:24 +09:00
みてるぞ 089727c153 #419 2026-09-23 14:40:59 +09:00
みてるぞ 894f9f9571 #419 2026-09-23 10:59:03 +09:00
みてるぞ 2490ed91c4 #419 2026-09-23 09:41:41 +09:00
みてるぞ 2dbd5260ea #419 2026-09-23 02:46:56 +09:00
みてるぞ 2e25c6e0ba #419 2026-09-22 20:33:46 +09:00
みてるぞ a0c2788e00 #419 2026-09-22 20:16:30 +09:00
みてるぞ 31fc32b377 #419 2026-09-22 19:22:16 +09:00
みてるぞ 068355720d #419 2026-09-22 15:13:14 +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
みてるぞ 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
みてるぞ 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
144個のファイルの変更7638行の追加5461行の削除
+162 -24
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
elements 4 spaces deeper than the statement's base indentation.
### Ruby delimiter and wrapping rules
@@ -140,6 +140,32 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
並べない。
- RSpec の `describe``context``it` 等の description が長い場合は、意味を
保ったまま一行へ収まる文言へ短縮する。
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
検討する。
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。
Bad:
@@ -191,6 +217,63 @@ end
Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb
it(
'returns 409 when stale changes '
'do not conflict'
) do
```
Good:
```rb
it 'returns mergeable 409 for stale non-conflicting changes' do
```
Bad:
```rb
records.each {
do_work(_1) }
@@ -225,20 +308,37 @@ records.each {
formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs
of 8 spaces are compressed to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
and alignment whitespace compress every complete run of 8 spaces to tabs.
- A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs.
- First determine visible indentation using 2-space block indentation and
4-space continuation indentation, then compress only complete leading runs
of 8 spaces into tabs.
- Tabs are only for leading indentation, never for spaces after non-space
text.
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
- In TypeScript and TSX, first determine visible indentation using 2-space
block indentation and 4-space continuation indentation, then compress every
complete run of 8 spaces used for indentation or column alignment to tabs.
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
- In TypeScript and TSX, this applies both to leading indentation and to
alignment whitespace after non-space text, such as aligned inline type
columns.
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
compression.
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
- In TypeScript and TSX, do not alter string literals, template-literal
contents, regular expressions, or user-facing text merely to apply tab
compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 16 columns = 2 tabs.
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
- When TypeScript or TSX code already uses column alignment, apply the same
8-space compression rule to that alignment whitespace.
- Example:
```ts
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void }
```
- TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations.
@@ -474,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked.
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
contracts, validation, routes, authentication, permissions, UI, copy,
dependencies, limits, thresholds, defaults, migrations, schema, or
environment settings to satisfy tests.
- Do not make production code match failing tests, mock assumptions, fixtures,
snapshots, old expectations, or stale setup. This includes changing
production constants merely because a test expects a different value.
- If test work reveals a production bug, spec mismatch, or missing behaviour,
stop without modifying production code and report: the failing test or
discovered issue, the related production file, the actual behaviour, the
expected behaviour, and why a production change appears necessary.
- Modify production code for test failures only when the user explicitly asks
for that production change. Do not expand a test task into a production task
on your own authority.
- If the user explicitly asks for both production implementation and test
updates, implement production code to the confirmed specification first,
then add or update tests to verify that specification. Never roll production
behaviour back to satisfy old tests.
- If it is unclear whether the test or the production implementation is stale,
or a test cannot be corrected without changing production code, ask the user
instead of guessing.
## Backend rules
@@ -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,
make dense controls wrap or scroll intentionally, and keep tag/filter
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
explicit, and ensure chip text remains readable in both light and dark modes.
- 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.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses
2 spaces per level, wrapped continuations use the repository's 4-space
continuation alignment, and complete leading runs of 8 spaces may be
compressed to tabs.
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
per level, wrapped continuations use the repository's 4-space continuation
alignment, and every complete run of 8 spaces used for indentation or
alignment has been compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
@@ -609,9 +738,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character.
- A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX, convert every complete run of 8 spaces used for
indentation or alignment to a tab character.
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
appears at the beginning of a line or in alignment whitespace after
non-space text.
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
attributes use 4-space continuation indentation; after visible columns are
determined, compress every complete run of 8 spaces in the resulting
indentation or alignment to tabs.
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
columns as spaces alone; convert each complete run of 8 spaces to tabs and
keep only the residual 2, 4, or 6 spaces.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
@@ -1254,9 +1392,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations
use the repository's 4-space continuation alignment, and complete leading
runs of 8 spaces may be compressed to tabs.
9. Block indentation uses 2 spaces per level, wrapped continuations use the
repository's 4-space continuation alignment, and every complete run of 8
spaces used for indentation or alignment has been compressed to tabs.
10. No line has trailing whitespace.
Preferred:
+7
ファイルの表示
@@ -85,4 +85,11 @@ class ApplicationController < ActionController::API
base_errors: },
status:
end
def normalise_json value
return nil if value.nil?
return JSON.parse(value) if value.is_a?(String)
value
end
end
+1 -1
ファイルの表示
@@ -237,7 +237,7 @@ class MaterialsController < ApplicationController
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag_name = TagName.find_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
+43 -25
ファイルの表示
@@ -15,30 +15,35 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
PostExternalTag
.select('external_tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id')
.to_sql
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q =
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
.includes(linked_tags: { tag_name: :wiki_page })
if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
@@ -46,21 +51,21 @@ class NicoTagsController < ApplicationController
sort_sql =
case order[0]
when 'name'
'tag_names.name'
'external_tags.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
"external_tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
tags = q.reselect('external_tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(
external_tag_json(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: }
@@ -72,8 +77,7 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
tag = ExternalTag.find(id)
linked_tag_names = params[:tags].to_s.split
linked_tags = nil
@@ -81,16 +85,15 @@ class NicoTagsController < ApplicationController
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags
tag.save!
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :update,
created_by_user: current_user)
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
@@ -102,6 +105,21 @@ class NicoTagsController < ApplicationController
private
def external_tag_json tag
{ id: tag.id,
name: "#{ tag.platform }:#{ tag.name }",
category: 'nico',
post_count: tag.post_count,
created_at: tag.created_at,
updated_at: tag.created_at,
deprecated_at: nil,
aliases: [],
parents: [],
has_wiki: false,
material_id: nil,
has_deerjikists: false }
end
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
-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
def index
post_id = params[:post].presence
tag_id = params[:tag].presence
tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
return head :bad_request if tag_id && external_tag_id
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -10,12 +13,6 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
tag_name =
if tag_id
TagName.joins(:tag).find_by(tag: { id: tag_id })
end
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
@@ -23,17 +20,23 @@ class PostVersionsController < ApplicationController
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
kw: "% #{ escaped } %")
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
'OR JSON_CONTAINS(prev.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
elsif tag_id
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
end
count = q.except(:select, :order, :limit, :offset).count
@@ -48,77 +51,91 @@ class PostVersionsController < ApplicationController
private
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags = split_tags(row.tags)
prev_tags = split_tags(row.attributes['prev_tags'])
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{
post_id: row.post_id,
{ post_id: row.post_id,
version_no: row.version_no,
event_type: row.event_type,
title: {
current: row.title,
prev: row.attributes['prev_title']
},
url: {
current: row.url,
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
title: { current: row.title, prev: row.attributes['prev_title'] },
url: { current: row.url, prev: row.attributes['prev_url'] },
thumbnail: { current: nil, prev: nil },
thumbnail_base: { current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base'] },
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
tags: build_version_tags(cur_tags, prev_tags),
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601
},
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 },
original_created_before: {
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601
},
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601 },
created_at: row.created_at.iso8601,
created_by_user:
if row.created_by_user_id
{
id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id]
}
end
}
if row.created_by_user_id
{ id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id] }
end }
end
end
def build_version_tags(cur_tags, prev_tags)
def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
NicoTagVersion
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end
def snapshot_tag_literals snapshots, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
[tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
def tag_category_order category
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
end
def build_version_tags cur_tags, prev_tags
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{
name:,
type:
}
{ name:, type: }
end
end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end
+261 -86
ファイルの表示
@@ -1,5 +1,6 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError
;
@@ -35,8 +36,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
PostTag
.select('post_id, MAX(updated_at) AS max_updated_at')
PostVersion
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -48,10 +49,9 @@ class PostsController < ApplicationController
filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -102,25 +102,96 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
post =
filtered_posts
.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
return head :not_found unless post
render json: PostRepr.base(post, current_user)
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
post =
Post
.includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -142,17 +213,41 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
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,
attributes: { title: params[:title], url: params[:url],
thumbnail: params[:thumbnail], 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] }).create!
attributes: post_create_attributes.merge(
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
: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!
post.reload
render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
@@ -161,6 +256,8 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue Post::RemoteThumbnailFetchFailed
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
@@ -169,6 +266,25 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
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
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
rescue ArgumentError => e
render_validation_error base: [e.message]
end
def viewed
return head :unauthorized unless current_user
@@ -267,50 +383,6 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def changes
id = params[:id].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
tag = TagRepr.base(pt.tag)
post = pt.post
events << Event.new(
post:,
tag:,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)
if pt.discarded_at
events << Event.new(
post:,
tag:,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private
def filtered_posts
@@ -356,8 +428,20 @@ class PostsController < ApplicationController
end
end
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def tagged_post_ids_for(name)
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
desired_tags.each do |t|
@@ -385,13 +469,13 @@ class PostsController < ApplicationController
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
@@ -435,7 +519,10 @@ class PostsController < ApplicationController
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) }
external_tags =
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
internal_tags + external_tags
end
def sibling_posts_by_parent parent_post_ids
@@ -463,6 +550,79 @@ class PostsController < ApplicationController
}.uniq
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
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
@@ -509,7 +669,10 @@ class PostsController < ApplicationController
end
def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
version.tags_json
.select { _1.key?('tag_id') }
.map { Post.tag_snapshot_literal(_1) }
.sort
end
def post_snapshot_from_record post
@@ -524,9 +687,7 @@ class PostsController < ApplicationController
def editable_tag_names_from_post post
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
@@ -542,9 +703,9 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -579,6 +740,23 @@ class PostsController < ApplicationController
value.to_s
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
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
@@ -670,15 +848,12 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
Tag.normalise_tags!(snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
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?)
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
return render_bad_request('URL は必須です.') if params[:url].blank?
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
image.auto_orient
image.resize '180x180>'
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
attachment =
Post.resized_thumbnail_attachment(
StringIO.new(Preview::ThumbnailFetcher.fetch(params[:url])))
send_data attachment[:io].read,
type: attachment[:content_type],
disposition: 'inline'
-2
ファイルの表示
@@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+320 -88
ファイルの表示
@@ -34,49 +34,155 @@ class TagsController < ApplicationController
offset = (page - 1) * limit
q =
tags =
if post_id.present?
Tag.joins(:posts, :tag_name)
Tag.joins(:posts, :tag_name).where(posts: { id: post_id })
else
Tag.joins(:tag_name)
end
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
if deprecated_given
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
external_tags =
if post_id.present?
ExternalTag.joins(:posts).where(posts: { id: post_id })
else
ExternalTag.all
end
if name
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
external_tags =
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
"%#{ name }%")
end
if category == 'nico'
tags = tags.none
elsif category
tags = tags.where(category:)
external_tags = external_tags.none
end
if post_count_between[0]
tags = tags.where('tags.post_count >= ?', post_count_between[0])
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
end
if post_count_between[1]
tags = tags.where('tags.post_count <= ?', post_count_between[1])
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
end
if created_between[0]
tags = tags.where('tags.created_at >= ?', created_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
end
if created_between[1]
tags = tags.where('tags.created_at <= ?', created_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
end
if updated_between[0]
tags = tags.where('tags.updated_at >= ?', updated_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
end
if updated_between[1]
tags = tags.where('tags.updated_at <= ?', updated_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
end
if deprecated_given
if deprecated
tags = tags.where.not(deprecated_at: nil)
external_tags = external_tags.none
else
tags = tags.where(deprecated_at: nil)
end
end
tag_sql = tags.select("'tag' AS source",
'tags.id',
'tag_names.name',
'tags.category',
'tags.post_count',
'tags.created_at',
'tags.updated_at',
'tags.deprecated_at').to_sql
external_tag_sql = external_tags.select(
"'external_tag' AS source",
'external_tags.id',
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
"'nico' AS category",
'external_tags.post_count',
'external_tags.created_at',
'external_tags.created_at AS updated_at',
'NULL AS deprecated_at').to_sql
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
if order[0] == 'category'
'CASE category ' +
"WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END"
"WHEN 'nico' THEN 6 " +
'END'
else
"tags.#{ order[0] }"
order[0]
end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
render json: { tags: TagRepr.many(tags), count: q.size }
connection = ApplicationRecord.connection
count = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
(#{ union_sql }) legacy_tags
SQL
rows = connection.select_all(<<~SQL).to_a
SELECT
source
, id
FROM
(#{ union_sql }) legacy_tags
ORDER BY
#{ sort_sql } #{ order[1] }
, id #{ order[1] }
, source #{ order[1] }
LIMIT
#{ limit }
OFFSET
#{ offset }
SQL
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
tags_by_id =
Tag
.joins(:tag_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
def with_depth
@@ -100,13 +206,14 @@ class TagsController < ApplicationController
def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true)
alias_rows =
TagName
.where('name LIKE ?', "#{ q }%")
.where('name LIKE ?', prefix)
.where.not(canonical_id: nil)
.pluck(:canonical_id, :name)
@@ -118,29 +225,43 @@ class TagsController < ApplicationController
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end
base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only
canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
render json: tags.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
return render json: internal_rows unless with_nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { external_tag_json(_1) }
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name']] }
.first(20)
render json: rows
end
def show
@@ -158,14 +279,20 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: TagRepr.base(tag)
else
head :not_found
end
tag =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return render json: TagRepr.base(tag) if tag
platform, external_name = name.split(':', 2)
return head :not_found unless external_name
external_tag = ExternalTag.find_by(platform:, name: external_name)
return head :not_found unless external_tag
render json: ExternalTagRepr.base(external_tag)
end
def deerjikists
@@ -200,14 +327,45 @@ class TagsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
row_indexes_by_key = rows_by_key(rows)
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
current_deerjikists = locked_deerjikists.filter { |deerjikist|
deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(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?
end
end
@@ -251,11 +409,7 @@ class TagsController < ApplicationController
parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated)
if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
if category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -303,13 +457,9 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico')
if category.present? && category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -516,11 +666,6 @@ class TagsController < ApplicationController
end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed
@@ -542,7 +687,7 @@ class TagsController < ApplicationController
return false
end
target_tag_name = TagName.with_discarded.find_by(name:)
target_tag_name = TagName.find_by(name:)
return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
@@ -554,17 +699,14 @@ class TagsController < ApplicationController
return if name == 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?
current_tag_name.update!(name:)
return
end
promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
@@ -574,11 +716,9 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil)
TagName.with_discarded
.where(canonical_id: current_tag_name.id)
TagName.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name)
@@ -609,7 +749,7 @@ class TagsController < ApplicationController
end
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
end
@@ -624,7 +764,7 @@ class TagsController < ApplicationController
end
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)
end
@@ -635,8 +775,7 @@ class TagsController < ApplicationController
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a
@@ -653,6 +792,7 @@ class TagsController < ApplicationController
end
def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
@@ -669,6 +809,82 @@ class TagsController < ApplicationController
nil
end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts 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
fields = { }
@@ -687,4 +903,20 @@ class TagsController < ApplicationController
render_validation_error fields:
end
def external_tag_json tag
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'deprecated_at' => nil,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'post_count' => tag.post_count,
'matched_alias' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
end
+1 -1
ファイルの表示
@@ -94,7 +94,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
tag_name = TagName.find_or_create_by!(name: title)
page =
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
+5 -19
ファイルの表示
@@ -1,24 +1,10 @@
class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, class_name: 'Tag'
belongs_to :tag, class_name: 'Tag'
belongs_to :nico_tag,
class_name: 'ExternalTag',
foreign_key: :nico_tag_id,
inverse_of: :nico_tag_relations
belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id
validates :nico_tag_id, presence: true
validates :tag_id, presence: true
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
def nico_tag_must_be_nico
if nico_tag && nico_tag.category != 'nico'
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end
end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true
end
+261 -26
ファイルの表示
@@ -1,7 +1,9 @@
class Post < ApplicationRecord
require 'date'
require 'mini_magick'
require 'nokogiri'
require 'stringio'
require 'timeout'
class RemoteThumbnailFetchFailed < StandardError; end
@@ -11,44 +13,69 @@ class Post < ApplicationRecord
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
def self.resized_thumbnail_attachment(upload)
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
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
image = MiniMagick::Image.read(upload.read)
image.resize '180x180^'
image.gravity 'Center'
image.extent '180x180'
image.format 'jpg'
bytes = upload.read
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
image = image_for_thumbnail_upload(bytes, content_type:)
image.auto_orient
image.resize '180x180'
image.format 'jpg'
image.to_blob
end
{ io: StringIO.new(image.to_blob),
{ io: StringIO.new(blob),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
rescue Timeout::Error
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
ensure
upload.rewind
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
has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :tags, through: :post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :active_post_tags, source: :tag
through: :post_tags,
source: :tag
has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all
has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
@@ -65,6 +92,9 @@ class Post < ApplicationRecord
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_many :post_external_tags, dependent: :destroy
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
@@ -98,7 +128,6 @@ class Post < ApplicationRecord
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
@@ -112,8 +141,46 @@ class Post < ApplicationRecord
end
end
def self.tag_snapshot_literal tag
sections = tag.fetch('sections', []).map do |sec|
begin_ms = sec.fetch('begin_ms')
end_ms = sec['end_ms']
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
"#{ tag.fetch('name') }#{ sections.join }"
end
def snapshot_tags_json
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
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end
def self.ms_to_time ms
@@ -157,17 +224,7 @@ class Post < ApplicationRecord
end
def attach_thumbnail_from_url! raw_url
response = Preview::ThumbnailFetcher.fetch_image_response(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
thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
end
private
@@ -209,6 +266,179 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip
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
raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field)
@@ -252,7 +482,12 @@ class Post < ApplicationRecord
match =
value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
/
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
(?::(\d{2})(?:\.(\d+))?)?
(Z|[+-]\d{2}:?\d{2})?
\z
/x)
return nil if match.nil?
year = match[1].to_i
+4
ファイルの表示
@@ -0,0 +1,4 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag, counter_cache: :post_count
end
+1 -21
ファイルの表示
@@ -1,14 +1,7 @@
class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
validates :post_id, uniqueness: { scope: :tag_id }
end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
+19 -42
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError
;
end
@@ -28,15 +26,12 @@ class Tag < ApplicationRecord
end
has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :posts, through: :post_tags
has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
class_name: 'NicoTagRelation',
foreign_key: :tag_id,
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -54,7 +49,6 @@ class Tag < ApplicationRecord
has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
@@ -69,17 +63,13 @@ class Tag < ApplicationRecord
character: 'character',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :nico_tag_name_must_start_with_nico
validate :tag_name_mustnt_start_with_nico
validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = {
'general:' => :general,
@@ -114,10 +104,9 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -234,10 +223,10 @@ class Tag < ApplicationRecord
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = TagName.find_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
Tag.find_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category
end
rescue ActiveRecord::RecordNotUnique
@@ -259,11 +248,11 @@ class Tag < ApplicationRecord
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
affected_post_ids << post_id
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag)
source_pt.destroy!
unless PostTag.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
end
@@ -275,14 +264,10 @@ class Tag < ApplicationRecord
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard!
source_tag.destroy!
if source_tag.nico?
source_tag_name.discard!
else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
@@ -293,13 +278,13 @@ class Tag < ApplicationRecord
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
target_tag.reload
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)
@@ -309,11 +294,9 @@ class Tag < ApplicationRecord
private
def nico_tag_name_must_start_with_nico
n = name.to_s
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
def tag_name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
@@ -354,10 +337,4 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end
-2
ファイルの表示
@@ -1,6 +1,4 @@
class TagName < ApplicationRecord
include MyDiscard
has_one :tag
has_one :wiki_page
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ class TagNameSanitisationRule < ApplicationRecord
elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
end
tn.discard!
tn.destroy!
next
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
def base post, current_user = nil
json = common(post)
def base post, current_user = nil, host: nil
json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
@@ -26,50 +31,98 @@ module PostRepr
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: []
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))
sibling_posts: { }, related: [], host: nil
if host.present?
base(post, current_user, host:).merge(
'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts, host:)
},
'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
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
def card post, host: nil
if host.present?
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
else
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
end
def cards posts
posts.map { |post| card(post) }
def cards posts, host: nil
if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
end
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
def many posts, current_user = nil, host: nil
if host.present?
posts.map { |p| base(p, current_user, host:) }
else
posts.map { |p| base(p, current_user) }
end
end
def common post
def common post, host: nil
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
def tag_json post
post
.active_post_tags
internal_tags =
post
.post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
external_tags =
post
.external_tags
.sort_by { _1.name }
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
internal_tags + external_tags
end
def thumbnail_url post
def thumbnail_url post, host: nil
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
options = { only_path: false }
options[:host] = host if host.present?
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
payload = {
post_id: post.id,
attachment_id: post.thumbnail.attachment&.id,
blob_id: post.thumbnail.blob&.id,
error_class: e.class,
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil
end
end
+9 -6
ファイルの表示
@@ -1,19 +1,22 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
def initialize external_tag:, event_type:, created_by_user:
super(record: external_tag, event_type:, created_by_user:)
end
private
def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :tag
def record_key = :external_tag
def snapshot_attributes
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
{ name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
def tracks_version_no_on_record? = false
end
+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
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(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 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!
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
+269
ファイルの表示
@@ -0,0 +1,269 @@
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!
direct_tag_specs, tag_sections = parse_direct_tag_specs
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(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
tag_sections = { }
direct_tag_specs = []
tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name]
raise Tag::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 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(name), category&.to_sym, sections]
end
def build_default_tag_specs direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
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 snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
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
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
end
end
def preload_existing_tags_by_name! names
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
missing_names = wanted_names - existing_tags_by_name.keys
return if missing_names.empty?
existing_tags_by_name.merge!(
Tag.joins(:tag_name)
.where(tag_names: { name: missing_names })
.index_by(&:name))
end
def canonical_tag_name_without_sections raw_name
name, = parse_raw_tag_name(raw_name)
name
end
def deerjikist_tag_spec? spec
return true if spec[:category] == :deerjikist
existing_tags_by_name[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 name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
(tag_name.canonical || 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
+85 -67
ファイルの表示
@@ -10,103 +10,122 @@ class PostCreator
end
def create!
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence,
uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence)
attach_thumbnail!(post)
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
snapshot_tags = planned_snapshot_tags
post_tags = planned_post_tags
sections = planned_sections
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
post.video_ms = planned_video_ms
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, planned_parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end
post
rescue StandardError
post&.thumbnail&.purge if post&.thumbnail&.attached?
raise
end
private
def attach_thumbnail! post
thumbnail = @attributes[:thumbnail]
if thumbnail.present?
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
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]
def prepare_thumbnail_attachment
PostThumbnailAttachmentBuilder.build(
thumbnail: @attributes[:thumbnail],
thumbnail_base: @attributes[:thumbnail_base].presence)
end
def tag_names = @attributes[:tags].to_s.split
def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
def 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
def planned_post_tags = planned_create_attributes[:post_tags]
id
}.uniq
def planned_sections = planned_create_attributes[:tag_sections]
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
def planned_video_ms
planned_create_attributes[:video_ms]
end
def normalise_video_ms tags
return nil unless tags.any? { _1.id == Tag.video.id }
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
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
post = Post.new
if begin_ms >= video_ms
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
def planned_create_attributes
@planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
@attributes[:tag_sections] || { },
snapshot_tags,
post_tags),
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
else
build_materialised_plan
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 specs
Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name']
category = spec[:category] || spec['category']
next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(name, category:)
tag.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
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
@@ -116,10 +135,9 @@ class PostCreator
end_ms:)
end
end
PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
tag_id: (current_ids - desired_ids).to_a).destroy_all
end
def sync_parent_posts! post, ids
+45 -26
ファイルの表示
@@ -3,13 +3,14 @@ require 'timeout'
class PostImportPreviewer
FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'parent_post_ids'].freeze
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'video_ms',
'duration',
'tags',
'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
@@ -125,7 +126,6 @@ class PostImportPreviewer
validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validation_errors,
field_warnings,
known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
attributes.delete('url')
@@ -162,6 +162,8 @@ class PostImportPreviewer
normalised =
if field == 'duration'
normalise_duration_attribute(value)
elsif field == 'video_ms'
value.nil? ? '' : value.to_s
else
value.to_s
end
@@ -194,7 +196,7 @@ class PostImportPreviewer
def clear_automatic_values! attributes, provenance, tag_sources
['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'
end
tag_sources['automatic'] = ''
@@ -223,15 +225,15 @@ class PostImportPreviewer
end
{ data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -252,7 +254,15 @@ class PostImportPreviewer
def sanitise_metadata_url value
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
def sanitise_metadata_time value
@@ -325,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url
fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
@@ -439,7 +449,7 @@ class PostImportPreviewer
[]
end
def validate_preview_tags raw, errors, field_warnings, known_tags
def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split
return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
@@ -451,11 +461,6 @@ class PostImportPreviewer
existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name)
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
errors[:tags] = ['タグ区間の記法が不正です.']
end
@@ -466,7 +471,7 @@ class PostImportPreviewer
thumbnail_base: attributes['thumbnail_base'].presence,
original_created_from: attributes['original_created_from'].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.errors.each do |error|
next if error.attribute == :url && error.type == :taken
@@ -475,7 +480,21 @@ class PostImportPreviewer
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?
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
TIMESTAMP_PATTERN =
Regexp.new(
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z')
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
(Z|[+-]\d{2}:?\d{2})?\z/x
def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url)
@@ -32,6 +31,7 @@ class PostMetadataFetcher
original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last),
duration: serialise_duration(duration),
display_tags: display_tags(platform_tags),
tags: platform_tags.join(' ') }
end
@@ -42,6 +42,24 @@ class PostMetadataFetcher
[]
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
return nil if value.blank?
@@ -168,6 +186,7 @@ class PostMetadataFetcher
end
private_class_method :platform_tags,
:display_tags,
:original_created_range,
:parse_timestamp_range,
: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,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '),
tags_json: @record.snapshot_tags_json,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
+8 -10
ファイルの表示
@@ -1,7 +1,7 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
HTML_MAX_BYTES = 1.megabyte
@@ -28,7 +28,9 @@ module Preview
def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url)
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, 'サムネール画像が見つかりませんでした.'
end
@@ -51,7 +53,9 @@ module Preview
return nil if url.blank?
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
rescue HttpFetcher::FetchTimeout
@@ -92,13 +96,7 @@ module Preview
nil
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!,
:niconico_thumbnail_url,
:allowed_image_content_type?
:niconico_thumbnail_url
end
end
+4 -20
ファイルの表示
@@ -1,32 +1,16 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
return if tag.tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
def self.record_tag_snapshot! tag, created_by_user:
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:)
end
+5 -1
ファイルの表示
@@ -47,10 +47,14 @@ class VersionRecorder
end
def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:)
@record.version_no = version_no
end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
@@ -60,7 +64,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists"
end
return unless latest
return if !(latest) || !(tracks_version_no_on_record?)
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
+8 -4
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time"
require 'active_support/core_ext/integer/time'
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else
config.action_controller.perform_caching = false
end
@@ -36,7 +36,11 @@ Rails.application.configure do
config.action_mailer.perform_caching = false
# 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.
config.active_support.deprecation = :log
+2 -8
ファイルの表示
@@ -31,13 +31,6 @@ Rails.application.routes.draw do
scope :preview, controller: :preview do
get :title
get :thumbnail
get :image
end
scope 'posts/import', controller: :post_imports do
post :preview
post :validate
post '', action: :create
end
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
collection do
get :metadata
post :bulk
get :random
get :changes
get :versions, to: 'post_versions#index'
end
+299
ファイルの表示
@@ -0,0 +1,299 @@
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
RESOLUTION_GRACE = 1.second
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTag < ActiveRecord::Base
self.table_name = 'tags'
end
class MigrationTagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
add_column :post_versions, :tags_json, :json, after: :tags
MigrationPostVersion.reset_column_information
backfill_missing_initial_tag_versions!
intervals_by_name = build_intervals_by_name
say_with_time 'Backfilling post_versions.tags_json' do
MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version|
version.update_columns(tags_json: build_tags_json(version, intervals_by_name))
end
end
change_column_null :post_versions, :tags_json, false
schema = connection.quote(JSON.generate({
type: 'array',
items: { type: 'object',
properties: { id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string', enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico'] },
sections: { type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer',
minimum: 0 },
end_ms: { type: ['integer',
'null'],
minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } } },
required: ['id', 'version_no', 'name', 'category', 'sections'],
additionalProperties: false } }))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def down
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
remove_column :post_versions, :tags_json
end
private
def backfill_missing_initial_tag_versions!
say_with_time 'Backfilling missing initial tag versions' do
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
tag_rows.length + nico_rows.length
end
end
def missing_initial_version_rows version_class, nico:
first_versions =
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
.transform_values(&:first)
rows = []
MigrationTag.find_each do |tag|
next if (tag.category == 'nico') != nico
first_version = first_versions[tag.id]
next unless first_version
next if valid_initial_version?(first_version)
assert_inferable_initial_version!(tag, first_version)
rows << initial_version_row(tag, first_version, nico:)
end
rows
end
def valid_initial_version? version
version.version_no == 1 && version.event_type == 'create'
end
def assert_inferable_initial_version! tag, version
inferable =
version.version_no == 2 &&
version.event_type == 'discard' &&
tag.created_at < version.created_at
return if inferable
details = [
"tag_id=#{ tag.id }",
"version_no=#{ version.version_no }",
"event_type=#{ version.event_type.inspect }",
"tag_created_at=#{ tag.created_at.iso8601(6) }",
"version_created_at=#{ version.created_at.iso8601(6) }"]
raise "Cannot infer initial tag version: #{ details.join(', ') }"
end
def initial_version_row tag, discard_version, nico:
row = {
tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: discard_version.name,
created_at: tag.created_at,
created_by_user_id: nil }
if nico
return row.merge(linked_tags: discard_version.linked_tags)
end
row.merge(
category: discard_version.category,
aliases: discard_version.aliases,
parent_tag_ids: discard_version.parent_tag_ids,
deprecated_at: discard_version.deprecated_at)
end
def build_intervals_by_name
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
versions_by_kind = {
tag: versions_by_tag_id(MigrationTagVersion),
nico: versions_by_tag_id(MigrationNicoTagVersion) }
current_names = current_names_by_tag_id
MigrationTag.find_each do |tag|
nico = tag.category == 'nico'
kind = nico ? :nico : :tag
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
intervals_for(
tag,
versions,
current_name: current_names.fetch(tag.id),
nico:).each do |interval|
name = interval.delete(:name)
intervals_by_name[name] << interval
end
end
intervals_by_name
end
def versions_by_tag_id version_class
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
end
def current_names_by_tag_id
MigrationTagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.pluck('tags.id', 'tag_names.name')
.to_h
end
def intervals_for tag, versions, current_name:, nico:
if versions.empty?
return [{
name: current_name,
tag_id: tag.id,
version_no: tag.version_no,
category: nico ? 'nico' : tag.category,
from: tag.created_at,
to: tag.discarded_at }]
end
versions.each_with_index.filter_map do |version, index|
next if version.event_type == 'discard'
{
name: version.name,
tag_id: tag.id,
version_no: version.version_no,
category: nico ? 'nico' : version.category,
from: version.created_at,
to: versions[index + 1]&.created_at || tag.discarded_at }
end
end
def build_tags_json version, intervals_by_name
entries = version.tags.to_s.split.map do |literal|
name = tag_name_from_literal(literal)
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
{ 'id' => interval.fetch(:tag_id),
'version_no' => interval.fetch(:version_no),
'name' => name,
'category' => interval.fetch(:category),
'sections' => [] }
end
assert_unique_tag_ids!(version, entries)
entries.sort_by { |entry| entry.fetch('id') }
end
def tag_name_from_literal literal
name = literal.dup
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
SECTION_LITERAL_PATTERN)
if name.empty? || name.include?('[') || name.include?(']')
raise "Invalid legacy tag literal: #{ literal.inspect }"
end
name
end
def resolve_tag! intervals, name:, version:
time = version.created_at
candidates = intervals.select do |interval|
interval.fetch(:from) <= time &&
(interval[:to].nil? || time < interval.fetch(:to))
end
candidates = future_candidates(intervals, time) if candidates.empty?
return candidates.first if candidates.one?
candidate_versions = candidates.map do |candidate|
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
end
details = [
"post_version_id=#{ version.id }",
"post_id=#{ version.post_id }",
"name=#{ name.inspect }",
"created_at=#{ time.iso8601(6) }",
"candidates=#{ candidate_versions.inspect }"].join(', ')
raise "Could not resolve tag snapshot: #{ details }"
end
def future_candidates intervals, time
candidates = intervals.select do |interval|
interval.fetch(:from) > time &&
interval.fetch(:from) <= time + RESOLUTION_GRACE
end
return [] if candidates.empty?
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
candidates.select do |interval|
interval.fetch(:from) == nearest_from
end
end
def assert_unique_tag_ids! version, entries
duplicate_tag_ids =
entries
.map { |entry| entry.fetch('id') }
.tally
.select { |_tag_id, count| count > 1 }
.keys
return if duplicate_tag_ids.empty?
details = [
"post_version_id=#{ version.id }",
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
raise "Duplicate tag IDs: #{ details }"
end
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class DeleteInactiveRecordsFromPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
post_tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :post_tags, [:tag_id, :discarded_at]
remove_index :post_tags, [:post_id, :discarded_at]
remove_index :post_tags, name: 'idx_post_tags_active_unique'
remove_index :post_tags, :discarded_at
remove_foreign_key :post_tags, column: :deleted_user_id
remove_index :post_tags, :deleted_user_id
remove_column :post_tags, :active_unique_key
remove_column :post_tags, :is_active
remove_column :post_tags, :discarded_at
remove_column :post_tags, :deleted_user_id
remove_column :post_tags, :updated_at
execute <<~SQL
ALTER TABLE
post_tags
MODIFY COLUMN
id BIGINT NOT NULL
SQL
execute <<~SQL
ALTER TABLE
post_tags
DROP PRIMARY KEY
SQL
remove_column :post_tags, :id
execute <<~SQL
ALTER TABLE
post_tags
ADD PRIMARY KEY
(post_id, tag_id)
SQL
remove_index :post_tags, :post_id
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
@@ -0,0 +1,11 @@
class AddForeignKeyOnPostIdAndTagIdInPostTagSections < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :post_tag_sections, :posts, column: :post_id
remove_foreign_key :post_tag_sections, :tags, column: :tag_id
add_foreign_key :post_tag_sections, :post_tags,
column: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
on_delete: :cascade
end
end
+46
ファイルの表示
@@ -0,0 +1,46 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+54
ファイルの表示
@@ -0,0 +1,54 @@
class CreateExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :external_tags do |t|
t.string :platform, limit: 16, null: false
t.string :name, limit: 255, null: false
t.integer :post_count, null: false, default: 0
t.datetime :created_at, null: false
t.index [:platform, :name], unique: true
end
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
t.id
, 'nico' AS platform
, SUBSTR(tn.name, 6) AS name
, t.post_count
, t.created_at
FROM
tags t
INNER JOIN
tag_names tn
ON
tn.id = t.tag_name_id
AND t.category = 'nico'
SQL
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
ntv.tag_id
, 'nico' AS platform
, SUBSTR(ntv.name, 6) AS name
, 0 AS post_count
, ntv.created_at
FROM
nico_tag_versions ntv
LEFT JOIN
external_tags et
ON
et.id = ntv.tag_id
WHERE
ntv.version_no = 1
AND et.id IS NULL
SQL
end
def down
drop_table :external_tags
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class CreatePostExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t|
t.references :post, null: false, index: false, foreign_key: true
t.references :external_tag, null: false, foreign_key: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
post_external_tags(post_id, external_tag_id, created_at)
SELECT
pt.post_id
, pt.tag_id AS external_tag_id
, pt.created_at
FROM
post_tags pt
INNER JOIN
tags t
ON
pt.tag_id = t.id
AND t.category = 'nico'
SQL
end
def down
drop_table :post_external_tags
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id
add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id
end
end
+199
ファイルの表示
@@ -0,0 +1,199 @@
class MigrateExternalTags < ActiveRecord::Migration[8.0]
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
def up
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
post_tag_sections pts
INNER JOIN
tags t
ON
t.id = pts.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "post_tag_sections に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
materials m
INNER JOIN
tags t
ON
t.id = m.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "materials に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.id = ti.tag_id
OR t.id = ti.parent_tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "tag_implications に #{ x } 件のチンカスがあります!"
end
execute <<~SQL
DELETE
ts
FROM
tag_similarities ts
INNER JOIN
tags t
ON
t.category = 'nico'
AND (t.id = ts.tag_id
OR t.id = ts.target_tag_id)
SQL
execute <<~SQL
DELETE
tset
FROM
theatre_skip_event_tags tset
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = tset.tag_id
SQL
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
say_with_time 'Migrate post_versions.tags_json' do
count = 0
MigrationPostVersion.find_each(batch_size: 500) do |version|
tags = version.tags_json.map do |tag|
if tag.fetch('category') == 'nico'
{ 'external_tag_id' => tag.fetch('id') }
else
{ 'tag_id' => tag.fetch('id'),
'version_no' => tag.fetch('version_no'),
'name' => tag.fetch('name'),
'category' => tag.fetch('category'),
'sections' => tag.fetch('sections') }
end
end
version.update_columns(tags_json: tags)
count += 1
end
count
end
add_tags_json_constraint!
tag_name_ids = connection.select_values(<<~SQL)
SELECT
tag_name_id
FROM
tags
WHERE
category = 'nico'
SQL
connection.transaction do
execute <<~SQL
DELETE
pt
FROM
post_tags pt
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = pt.tag_id
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
category = 'nico'
SQL
unless tag_name_ids.empty?
execute <<~SQL
DELETE
FROM
tag_names
WHERE
id IN (#{ tag_name_ids.join(', ') })
SQL
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def add_tags_json_constraint!
schema = { type: 'array',
items: { oneOf: [internal_tag_schema, external_tag_schema] } }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def internal_tag_schema
{ type: 'object',
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string',
enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta'] },
sections: sections_schema },
required: ['tag_id', 'version_no', 'sections'],
additionalProperties: false }
end
def external_tag_schema
{ type: 'object',
properties: { external_tag_id: { type: 'integer', minimum: 1 } },
required: ['external_tag_id'],
additionalProperties: false }
end
def sections_schema
{ type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer', minimum: 0 },
end_ms: { type: ['integer', 'null'], minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } }
end
end
生成ファイル
+36 -37
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -48,6 +48,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
t.integer "post_count", default: 0, null: false
t.datetime "created_at", null: false
t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false
t.integer "input_tokens", default: 0, null: false
@@ -281,6 +289,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "external_tag_id", null: false
t.datetime "created_at", null: false
t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
@@ -311,23 +326,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end
@@ -349,6 +353,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.json "tags_json", null: false
t.text "parent_post_ids", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
@@ -362,6 +367,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -393,19 +399,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false
@@ -431,9 +424,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true
end
@@ -470,10 +461,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -614,6 +603,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -707,27 +709,25 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_external_tags", "external_tags"
add_foreign_key "post_external_tags", "posts"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
@@ -737,7 +737,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
+57 -33
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -30,9 +30,28 @@ namespace :nico do
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!)
end
def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil
current_external_tag_ids ||=
PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set
desired_external_tag_ids = desired_external_tag_ids.compact.to_set
to_add = desired_external_tag_ids - current_external_tag_ids
to_remove = current_external_tag_ids - desired_external_tag_ids
ExternalTag.where(id: to_add.to_a).find_each do |external_tag|
begin
PostExternalTag.create!(post:, external_tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end
mysql_user = ENV['MYSQL_USER']
@@ -111,58 +130,63 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
tags = post.tags
# 既存のタグ Id. 集合
kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
kept_tag_ids = post.tags.pluck(:id).to_set
# 既存の外部タグ Id. 集合
kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set
# 記載すべき外部タグ Id. のリスト
desired_external_tag_ids = []
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
desired_nico_tag_based_ids = []
# 記載すべき内部タグ Id. のリスト
desired_non_nico_tag_ids = []
desired_tag_ids = kept_tag_ids.to_a
datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw)
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
unless tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :create,
created_by_user: nil)
end
desired_nico_tag_based_ids << tag.id
desired_external_tag_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載
unless tag.id.in?(kept_tag_ids)
linked_ids = tag.linked_tags.pluck(:id)
desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
# 連携タグは記載すれども消除せず.
unless tag.id.in?(kept_external_tag_ids)
desired_tag_ids.concat(tag.linked_tags.pluck(:id))
end
end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist
desired_non_nico_tag_ids << deerjikist.tag_id
desired_nico_tag_based_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?)
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
desired_tag_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?)
desired_tag_ids << Tag.no_deerjikist.id
end
desired_nico_tag_based_ids.uniq!
desired_external_tag_ids.uniq!
desired_tag_ids.uniq!
desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_non_nico_tag_ids.uniq!
if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ.
if kept_tag_ids != desired_tag_ids.to_set
desired_tag_ids << Tag.bot.id
desired_tag_ids.uniq!
end
desired_all_tag_ids.uniq!
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
tags_changed =
kept_tag_ids != desired_tag_ids.to_set ||
kept_external_tag_ids != desired_external_tag_ids.to_set
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
sync_post_external_tags!(post, desired_external_tag_ids,
current_external_tag_ids: kept_external_tag_ids)
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
elsif post_changed || tags_changed
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
+123
ファイルの表示
@@ -0,0 +1,123 @@
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
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
-7
ファイルの表示
@@ -11,12 +11,5 @@ FactoryBot.define do
after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present?
end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
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
+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
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
it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
@@ -110,7 +129,7 @@ RSpec.describe Post, type: :model do
end
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(
width: 360,
height: 180,
@@ -123,13 +142,13 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 180])
expect_green(colour_at(image, 0, 90))
expect_green(colour_at(image, 90, 90))
expect_green(colour_at(image, 179, 90))
expect(image.dimensions).to eq([180, 90])
expect_red(colour_at(image, 0, 45))
expect_green(colour_at(image, 90, 45))
expect_red(colour_at(image, 179, 45))
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(
width: 180,
height: 360,
@@ -142,10 +161,10 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
expect(image.dimensions).to eq([180, 180])
expect_green(colour_at(image, 90, 0))
expect_green(colour_at(image, 90, 90))
expect_green(colour_at(image, 90, 179))
expect(image.dimensions).to eq([90, 180])
expect_red(colour_at(image, 45, 0))
expect_green(colour_at(image, 45, 90))
expect_red(colour_at(image, 45, 179))
end
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
image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180])
expect(image.dimensions).to eq([180, 135])
end
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
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
@@ -12,18 +80,25 @@ RSpec.describe PostTag, type: :model do
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
post_tag = create(:post_tag)
post = post_tag.post
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
own_section = create(:post_tag_section,
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
expect(post_tag.reload.sections).to contain_exactly(own_section)
end
it 'allows open-ended sections' do
+1
ファイルの表示
@@ -19,6 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
+28 -6
ファイルの表示
@@ -57,7 +57,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'deletes the source tag_name' do
described_class.apply!
expect(TagName.exists?(source.id)).to be(false)
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar')
end
end
@@ -75,7 +75,27 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.exists?(source_tag_name_id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:alias_name) do
TagName.create!(name: 'foobar', canonical: existing_tag.tag_name)
end
let!(:source) do
TagName.create!(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 canonical tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload.canonical).to eq(existing_tag.tag_name)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end
end
@@ -92,13 +112,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
it 'merges the source tag into the existing tag and deletes the source tag_name' do
expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
post = create(:post)
PostTag.create!(post:, tag: source_tag)
described_class.apply!
expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_id)).to be(false)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(existing_tag.reload.name).to eq('foobar')
end
end
end
+172 -67
ファイルの表示
@@ -1,6 +1,29 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, name:)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!([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
it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag')
@@ -159,17 +182,44 @@ RSpec.describe Tag, type: :model do
end
end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
expect {
tag = described_class.find_or_create_by_tag_name!(
' 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 canonical tag for an alias without changing its category' do
tag = create(:tag, category: :character)
alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name)
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(
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')
end
it 'creates a tag for an existing canonical name reached through an alias' do
canonical = create(:tag_name)
alias_name = TagName.create!(name: 'lookup_alias', canonical:)
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(0)
expect(tag.tag_name).to eq(canonical)
end
end
@@ -185,18 +235,14 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
@@ -206,38 +252,101 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
target_links = PostTag.where(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active.count).to eq(1)
expect(active.first.id).to eq(target_post_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = source_tag.name
source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name)
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
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(Tag.find(target_tag.id)).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
@@ -260,18 +369,16 @@ RSpec.describe Tag, type: :model do
)
end
it 'still merges, but discards the source tag_name instead of aliasing it' do
it 'still merges and keeps the source name as an alias without validating it' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -288,38 +395,32 @@ RSpec.describe Tag, type: :model do
message: 'init')
end
it 'rolls back the transaction' do
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do
earlier_source = create(:tag)
earlier_name = earlier_source.tag_name
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect {
described_class.merge_tags!(target_tag, [source_tag])
described_class.merge_tags!(target_tag, [earlier_source, source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(earlier_name.reload.canonical_id).to be_nil
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
.to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
context 'when merging a nico source tag' do
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
discarded_source_tag = Tag.with_discarded.find(source_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end
@@ -333,6 +434,7 @@ RSpec.describe Tag, type: :model do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
@@ -364,12 +466,15 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no active post_tags' do
context 'when the source tag has no post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
@@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
@@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
+76 -37
ファイルの表示
@@ -3,10 +3,34 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do
create_list(:tag, 3, :nico)
it 'returns the legacy Tag-compatible external fields' do
external = create(:external_tag, name: 'legacy_external', post_count: 3)
get '/tags/nico', params: { page: 2, limit: 2 }
get '/tags/nico', params: { name: 'legacy_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including(
'id' => external.id,
'name' => 'nico:legacy_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'linked_tags' => []))
end
it 'returns paginated tags and total count' do
3.times { |i| create(:external_tag, name: "pagination_#{ i }") }
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1)
@@ -14,12 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
linked = create(:external_tag)
linked.update!(name: 'search_linked')
unlinked = create(:external_tag)
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -41,27 +65,41 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'filters by the qualified legacy name as well as the raw name' do
external = create(:external_tag, name: 'qualified_filter')
create(:external_tag, name: 'unrelated_filter')
['qualified_filter', 'nico:qualified_filter'].each do |name|
get '/tags/nico', params: { name: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
end
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older = create(:external_tag)
older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
PostExternalTag.create!(post: create(:post), external_tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
PostExternalTag.create!(post: create(:post), external_tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
@@ -71,7 +109,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) }
let(:nico_tag) { create(:external_tag) }
it '401 when not logged in' do
sign_out
@@ -85,18 +123,18 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden)
end
it '400 when target is not nico category' do
it '404 when only an internal tag exists for the target id' do
sign_in_as(member)
non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:not_found)
end
it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
@@ -104,7 +142,8 @@ RSpec.describe 'NicoTags', type: :request do
linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: admin)
expect {
patch "/tags/nico/#{nico_tag.id}", params: {
@@ -126,26 +165,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'
])
'nico_linked_b'])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it 'returns 422 when linked tag normalises to nico tag' do
it 'clears existing links and records the empty mapping for a member' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
linked = create(:tag)
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: member)
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
}.to change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
expect(response).to have_http_status(:ok)
expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
end
it 'returns the tags field error when a nico tag is specified directly' do
-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
+568 -154
ファイルの表示
@@ -9,20 +9,19 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
allow(Post).to receive(:resized_thumbnail_attachment) do
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
end
def create_nico_tag!(name)
Tag.find_or_create_by_tag_name!(name, category: :nico)
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:'))
end
def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる)
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
real_thumbnail_upload
end
def real_thumbnail_upload
@@ -56,6 +55,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
@@ -93,6 +93,26 @@ RSpec.describe 'Posts API', type: :request do
count
end
def expect_external_tag_json tag_json, external_tag
external_tag.reload
expect(tag_json).to include(
'id' => external_tag.id,
'name' => "#{ external_tag.platform }:#{ external_tag.name }",
'category' => 'nico',
'created_at' => external_tag.created_at.as_json,
'updated_at' => external_tag.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'post_count' => external_tag.post_count,
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'children' => [],
'sections' => [])
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -102,34 +122,34 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe "GET /posts" do
describe 'GET /posts' do
let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: "spec_tag") }
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world",
Post.create!(uploaded_user: user, title: 'hello spec world',
url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:)
end
end
let!(:miss_post) do
Post.create!(uploaded_user: user, title: "unrelated title",
Post.create!(uploaded_user: user, title: 'unrelated title',
url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2)
end
end
it "returns posts with tag name in JSON" do
get "/posts"
it 'returns posts with tag name in JSON' do
get '/posts'
expect(response).to have_http_status(:ok)
posts = json.fetch("posts")
posts = json.fetch('posts')
# 全postの全tagが name を含むこと
expect(posts).not_to be_empty
@@ -142,18 +162,21 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
expect(all_tag_names).to include("spec_tag")
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
expect(all_tag_names).to include('spec_tag')
end
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: hit_post, tag: deprecated_tag)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
expect(hit_json.fetch('tags').map { |item| item.fetch('id') }).to eq([tag.id])
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
@@ -162,9 +185,29 @@ RSpec.describe 'Posts API', type: :request do
])
end
context "when q is provided" do
it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" }
it 'preloads tag details and sections as the number of posts grows' do
5.times do
link = create(:post_tag, post: create(:post, uploaded_user: user))
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
get '/posts', params: { limit: 1 }
one_post_queries = count_sql_queries do
get '/posts', params: { limit: 1 }
end
many_post_queries = count_sql_queries do
get '/posts', params: { limit: 20 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').size).to eq(8)
expect(many_post_queries).to be <= one_post_queries
end
context 'when q is provided' do
it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -182,8 +225,8 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "filters posts by q (hit case by alias)" do
get "/posts", params: { tags: "manko" }
it 'filters posts by q (hit case by alias)' do
get '/posts', params: { tags: 'manko' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -201,15 +244,102 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "returns empty posts when nothing matches" do
get "/posts", params: { tags: "no_such_keyword_12345" }
it 'returns empty posts when nothing matches' do
get '/posts', params: { tags: 'no_such_keyword_12345' }
expect(response).to have_http_status(:ok)
expect(json.fetch("posts")).to eq([])
expect(json.fetch('posts')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
context 'with legacy external tag name searches' do
let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') }
let!(:both_post) do
create(:post).tap do |post|
PostTag.create!(post:, tag:)
PostExternalTag.create!(post:, external_tag:)
end
end
before do
PostExternalTag.create!(post: miss_post, external_tag:)
end
it 'keeps internal name searches independent of colliding external ids' do
get '/posts', params: { tags: tag.name }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, both_post.id)
end
it 'finds posts through PostExternalTag by the qualified legacy name' do
get '/posts', params: { tags: 'nico:search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
[nil, 'all'].each do |match|
it "intersects internal and external matches with match=#{ match || 'omitted' }" do
params = { tags: "#{ tag.name } nico:search_external" }
params[:match] = match if match
get '/posts', params: params
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id])
end
end
it 'unions internal alias and external matches without duplicate posts' do
get '/posts', params: { tags: 'manko nico:search_external', match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(4)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id)
end
it 'excludes external matches from an internal tag search' do
get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id)
end
it 'unions an external match with a negated internal match' do
get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
it 'keeps a missing qualified external name empty' do
get '/posts', params: { tags: 'nico:missing_search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(0)
expect(json.fetch('posts')).to be_empty
end
it 'applies the same mixed name search to the existing random endpoint' do
get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' }
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(both_post.id)
end
end
context 'when tags contain not:' do
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
@@ -460,6 +590,71 @@ RSpec.describe 'Posts API', type: :request do
end
end
context 'when update times include version history' do
let(:t0) { Time.zone.parse('2020-01-01 12:00:00') }
let(:t1) { t0 + 1.day }
let(:t2) { t0 + 2.days }
let(:t3) { t0 + 3.days }
let!(:history_post) do
create(:post, url: 'https://example.com/version-time/history',
created_at: t0, updated_at: t0)
end
let!(:plain_post) do
create(:post, url: 'https://example.com/version-time/plain',
created_at: t1, updated_at: t1)
end
let!(:newer_post) do
create(:post, url: 'https://example.com/version-time/newer',
created_at: t0, updated_at: t3)
end
before do
link = create(:post_tag, post: history_post, tag:)
travel_to(t0) do
PostVersionRecorder.record!(post: history_post,
event_type: :create, created_by_user: nil)
PostVersionRecorder.record!(post: newer_post,
event_type: :create, created_by_user: nil)
end
travel_to(t2) do
link.destroy!
PostVersionRecorder.record!(post: history_post,
event_type: :update, created_by_user: nil)
end
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected_ids = [plain_post.id, history_post.id, newer_post.id]
expected_ids.reverse! if direction == 'desc'
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq(expected_ids)
expect(json.fetch('count')).to eq(3)
times = json.fetch('posts').to_h do |item|
[item.fetch('id'), Time.zone.parse(item.fetch('updated_at'))]
end
expect(times).to eq({ plain_post.id => t1,
history_post.id => t2,
newer_post.id => t3 })
expect(history_post.reload.updated_at).to eq(t0)
end
end
it 'filters inclusively by the latest version time after a tag is deleted' do
get '/posts', params: { url: '/version-time/',
updated_from: t2.iso8601,
updated_to: t2.iso8601 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?`
@@ -524,6 +719,30 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(2)
end
end
it 'returns internal and external tags with colliding ids in the legacy tags array' do
external_tag = create(:external_tag, id: tag.id, name: 'post_index_external')
PostExternalTag.create!(post: hit_post, external_tag:)
get '/posts'
expect(response).to have_http_status(:ok)
post_json =
json
.fetch('posts')
.find { _1.fetch('id') == hit_post.id }
external_json =
post_json
.fetch('tags')
.find { _1['name'] == 'nico:post_index_external' }
expect(post_json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end
describe 'GET /posts/:id' do
@@ -681,6 +900,25 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45
end
it 'returns external tags as root nodes in the legacy tag tree' do
external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external')
PostExternalTag.create!(post: post_record, external_tag:)
request
expect(response).to have_http_status(:ok)
expect(json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
external_json =
json
.fetch('tags')
.find { _1['name'] == 'nico:post_detail_external' }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end
context 'when post does not exist' do
@@ -693,6 +931,73 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'GET /posts/metadata' do
let(:member) { create(:user, :member) }
it 'returns compact existing post data without fetching external metadata' do
sign_in_as(member)
existing = create(
:post,
title: 'existing post',
url: 'https://example.com/existing')
existing.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
expect(PostMetadataFetcher).not_to receive(:fetch)
get '/posts/metadata', params: { url: 'https://example.com/existing' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => existing.url,
'existing_post_id' => existing.id,
'field_warnings' => { })
expect(json.fetch('existing_post')).to include(
'id' => existing.id,
'title' => existing.title,
'url' => existing.url,
'thumbnail_base' => existing.thumbnail_base)
expect(json.dig('existing_post', 'thumbnail'))
.to include('/rails/active_storage/blobs/proxy/')
end
it 'returns fetched metadata and structured display tags' do
sign_in_as(member)
allow(Preview::UrlSafety).to receive(:validate)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: 'https://example.com/thumbnail.jpg',
tags: 'character:虹夏',
display_tags: [{ name: '虹夏', category: 'character' }],
original_created_from: nil,
original_created_before: nil,
duration: '1:00',
video_ms: 60_000)
get '/posts/metadata', params: { url: 'https://example.com/new' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/new',
'title' => 'fetched title',
'duration' => '1:00',
'video_ms' => 60_000,
'field_warnings' => { })
expect(json.fetch('display_tags')).to eq(
[{ 'name' => '虹夏', 'category' => 'character' }])
end
it 'returns URL validation errors as 422' do
sign_in_as(member)
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to have_key('url')
end
end
describe 'POST /posts' do
let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
@@ -712,6 +1017,46 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:forbidden)
end
it 'dry-runs without persisting posts or new tags' do
sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count]
post '/posts?dry=1', params: post_write_params(
title: 'dry-run post',
url: 'https://example.com/dry-run',
tags: 'character:new_dry_run_tag')
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/dry-run',
'tags' => 'new_dry_run_tag',
'existing_post_id' => nil)
expect(json.fetch('display_tags')).to eq(
[{ 'name' => 'new_dry_run_tag',
'category' => 'character',
'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
end
it 'dry-runs an existing URL without validating its upload' do
sign_in_as(member)
existing = create(:post, url: 'https://example.com/dry-existing')
invalid_upload = Rack::Test::UploadedFile.new(
StringIO.new('<svg></svg>'),
'image/png',
original_filename: 'thumbnail.png')
post '/posts?dry=1', params: post_write_params(
title: '',
url: existing.url,
tags: '',
thumbnail: invalid_upload)
expect(response).to have_http_status(:ok)
expect(json).to include('existing_post_id' => existing.id)
expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
end
it '201 and creates post + tags when member' do
sign_in_as(member)
@@ -769,7 +1114,8 @@ RSpec.describe 'Posts API', type: :request do
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions])
expect(open_transactions).to eq(
[baseline_open_transactions, baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -827,7 +1173,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.']
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
)
end
@@ -1054,11 +1400,9 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when the external nico tag already exists' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
create(:external_tag, name: 'nico_tag')
end
it 'returns 422 with tag field errors' do
@@ -1251,6 +1595,47 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'POST /posts/bulk' do
let(:member) { create(:user, :member) }
it 'parses the manifest and indexed thumbnail parts' do
sign_in_as(member)
manifest = [{ 'url' => 'https://example.com/bulk',
'title' => 'bulk post',
'tags' => 'spec_tag',
'parent_post_ids' => '' }]
creator = instance_double(
PostBulkCreator,
run: { results: [{ status: 'created', post: { id: 123 } }] })
allow(PostBulkCreator).to receive(:new).and_return(creator)
post '/posts/bulk', params: {
posts: JSON.generate(manifest),
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:ok)
expect(json.fetch('results')).to eq(
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
expect(PostBulkCreator).to have_received(:new) do |arguments|
expect(arguments[:actor]).to eq(member)
expect(arguments[:posts]).to eq(manifest)
expect(arguments[:thumbnails].keys).to eq([0])
expect(arguments[:host]).to eq('http://www.example.com')
end
end
it 'rejects malformed manifests as a request-level error' do
sign_in_as(member)
post '/posts/bulk', params: {
posts: '{',
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
end
end
describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) }
@@ -1270,9 +1655,11 @@ RSpec.describe 'Posts API', type: :request do
it '200 and updates title + resync tags when member' do
sign_in_as(member)
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: 2000)
tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general)
replacement_tag = Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
@@ -1285,6 +1672,38 @@ RSpec.describe 'Posts API', type: :request do
names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2')
expect(names).not_to include('spec_tag')
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
expect(PostTagSection.exists?(post: post_record, tag:)).to be(false)
expect(tag.reload.post_count).to eq(0)
expect(replacement_tag.reload.post_count).to eq(1)
versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include(
a_hash_including('tag_id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') })
.not_to include(tag.id)
end
it 'can add a removed tag again and records both changes' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params(post_record, tags: '')
expect(response).to have_http_status(:ok)
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
put "/posts/#{ post_record.id }", params: post_update_params(
post_record, tags: 'spec_tag')
expect(response).to have_http_status(:ok)
expect(PostTag.where(post: post_record, tag:).count).to eq(1)
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('tag_id') }
end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end
it 'rejects a deprecated tag specified directly' do
@@ -1307,11 +1726,9 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when the external nico tag already exists' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
create(:external_tag, name: 'nico_tag')
end
it 'returns 422 with tag field errors' do
@@ -1564,7 +1981,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
@@ -1628,7 +2045,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
PostVersionRecorder.record!(
post: post_record.reload,
@@ -1648,14 +2065,14 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
end
it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
base_version = create_post_version_for!(post_record.reload)
@@ -1670,7 +2087,15 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
external_json =
json
.fetch('tags')
.find { _1['name'] == "nico:#{ nico_tag.name }" }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, nico_tag)
end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
@@ -1680,7 +2105,7 @@ RSpec.describe 'Posts API', type: :request do
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
@@ -1694,7 +2119,7 @@ RSpec.describe 'Posts API', type: :request do
names = post_record.reload.tags.map(&:name)
expect(names).to include(nico_tag.name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name)
@@ -1730,123 +2155,29 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:not_found)
end
it '200 and returns viewed boolean' do
it 'returns viewed state and current tags with their sections' do
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: post_record, tag: deprecated_tag)
get '/posts/random'
expect(response).to have_http_status(:ok)
expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed'])
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => tag.id,
'children' => [],
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }]))
end
end
describe 'GET /posts/changes' do
let(:member) { create(:user, :member) }
it 'returns 404 for the retired history endpoint' do
get '/posts/changes'
it 'returns add/remove events (history) for a post' do
# add
tn2 = TagName.create!(name: 'spec_tag2')
tag2 = Tag.create!(tag_name: tn2, category: :general)
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
# remove (discard)
pt.discard_by!(member)
get '/posts/changes', params: { id: post_record.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['changes']).to be_an(Array)
expect(json['count']).to be >= 2
types = json['changes'].map { |e| e['change_type'] }.uniq
expect(types).to include('add')
expect(types).to include('remove')
end
it 'filters history by tag' do
tn2 = TagName.create!(name: 'history_tag_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other post',
url: 'https://example.com/history-other'
)
# hit: add
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# hit: add + remove
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss: add + remove
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(3)
changes = json.fetch('changes')
expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
post_record.id,
other_post.id,
other_post.id
])
end
it 'filters history by post and tag together' do
tn2 = TagName.create!(name: 'history_tag_combo_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_combo_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other combo post',
url: 'https://example.com/history-combo-other'
)
# hit
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# miss by post
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss by tag
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { id: post_record.id, tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(1)
changes = json.fetch('changes')
expect(changes.size).to eq(1)
expect(changes[0]['change_type']).to eq('add')
expect(changes[0].dig('post', 'id')).to eq(post_record.id)
expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
end
it 'returns empty history when tag does not match' do
tn2 = TagName.create!(name: 'history_tag_no_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('changes')).to eq([])
expect(json.fetch('count')).to eq(0)
expect(response).to have_http_status(:not_found)
end
end
@@ -1876,6 +2207,7 @@ RSpec.describe 'Posts API', type: :request do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
@@ -1897,7 +2229,7 @@ RSpec.describe 'Posts API', type: :request do
end
let!(:v2) do
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
post_record.post_tags.find_by!(tag: tag).destroy!
PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!(
title: 'updated spec post',
@@ -2006,6 +2338,88 @@ RSpec.describe 'Posts API', type: :request do
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end
context 'with external tag history' do
let(:external_id) { tag.id }
let(:external) { create(:external_tag, id: external_id) }
let(:external_post) { create(:post) }
before do
PostExternalTag.create!(post: external_post, external_tag: external)
PostVersionRecorder.record!(
post: external_post, event_type: :create, created_by_user: member)
unrelated_post = create(:post)
PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag))
PostVersionRecorder.record!(
post: unrelated_post, event_type: :create, created_by_user: member)
end
it 'prefers Tag over ExternalTag for the legacy tag parameter' do
get '/posts/versions', params: { tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] })
.to contain_exactly(
[post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1])
end
it 'explicitly filters ExternalTag even when its id collides with Tag' do
get '/posts/versions', params: { external_tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
# Temporary compatibility shim until the frontend sends external_tag explicitly.
context 'with legacy tag fallback and no internal Tag with the external id' do
let(:external_id) { Tag.maximum(:id).to_i + 10_000 }
it 'falls back to ExternalTag for the legacy tag parameter' do
expect(Tag.exists?(external.id)).to be(false)
get '/posts/versions', params: { tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
[:tag, :external_tag].each do |parameter|
it "includes external removal history through the legacy API's #{ parameter }" do
external_post.post_external_tags.destroy_all
PostVersionRecorder.record!(
post: external_post.reload, event_type: :update, created_by_user: member)
get '/posts/versions', params: { parameter => external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1),
a_hash_including('post_id' => external_post.id, 'version_no' => 2))
end
end
end
end
it 'can render history containing external identifiers' do
PostExternalTag.create!(post: post_record, external_tag: create(:external_tag))
post_record.update_columns(version_no: 2)
PostVersionRecorder.record!(post: post_record,
event_type: :update, created_by_user: member)
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include(
'name' => tag2.name, 'type' => 'context')
end
it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
@@ -2271,9 +2685,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.iso8601('2020-01-01T00:00Z'))
.to eq(Time.utc(2020, 1, 1, 0, 0))
expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.iso8601('2020-01-01T00:01Z'))
.to eq(Time.utc(2020, 1, 1, 0, 1))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
-58
ファイルの表示
@@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end
describe "DELETE /tag_children" do
@@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end
end
+83
ファイルの表示
@@ -226,6 +226,14 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2],
)
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
context 'when tag already has deerjikists' do
@@ -299,6 +307,81 @@ RSpec.describe 'Tags deerjikists API', type: :request do
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
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
+197 -70
ファイルの表示
@@ -30,6 +30,30 @@ RSpec.describe 'Tags API', type: :request do
end
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
get '/tags'
@@ -164,20 +188,79 @@ RSpec.describe 'Tags API', type: :request do
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
Tag.create!(tag_name: TagName.create!(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 }
expect(response).to have_http_status(:ok)
expect(response_names).to eq(%w[
cat_deerjikist
cat_meme
cat_character
cat_general
cat_material
cat_meta
nico:cat_nico
])
expect(response_names).to eq([
'cat_deerjikist', 'cat_meme', 'cat_character',
'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico'])
expect(json.fetch('count')).to eq(7)
end
context 'with mixed legacy pagination' do
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
it 'paginates and keeps total count' do
@@ -299,6 +382,89 @@ RSpec.describe 'Tags API', type: :request do
end
describe 'GET /tags/autocomplete' do
it 'combines internal and external matches without conflating equal ids' do
internal = Tag.create!(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') }"
Tag.create!(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 = Tag.create!(category: :general, name: 'switch_internal', post_count: 1)
alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1)
TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name)
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 canonical, alias, and external names" do
literal = "literal#{ wildcard }match"
Tag.create!(category: :general, name: literal, post_count: 1)
alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1)
TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name)
create(:external_tag, name: literal, post_count: 1)
Tag.create!(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
get '/tags/autocomplete', params: { q: 'spec' }
@@ -340,6 +506,27 @@ RSpec.describe 'Tags API', type: :request do
end
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
get "/tags/name/#{ CGI.escape('spec_tag') }"
@@ -487,18 +674,6 @@ RSpec.describe 'Tags API', type: :request do
expect(json.fetch('deprecated_at')).to be_present
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
expect {
patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -508,32 +683,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general')
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
wiki_page =
Wiki::Commit.create_content!(
@@ -1169,28 +1318,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general')
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
system_tag = Tag.tagme
old_name = system_tag.name
-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
-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
+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| Tag.create!(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
+59
ファイルの表示
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe PostBulkCreator do
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
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)
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.fetch(:results)
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
+95
ファイルの表示
@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe PostCreatePlan do
def create_tag! name, category
Tag.create!(name:, category:)
end
before do
create_tag!('タグ希望', :meta)
create_tag!('ニジラー情報不詳', :meta)
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!
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 canonical names' do
canonical = create_tag!('虹夏', :character)
TagName.create!(name: 'にじか', canonical: canonical.tag_name)
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!
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!
}.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!
}.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!
expect(plan[:duration]).to eq('invalid')
expect(plan[:video_ms]).to be_nil
end
end
+15 -19
ファイルの表示
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
allow(PostVersionRecorder).to receive(:record!)
end
it 'prefers an explicit upload over thumbnail_base' do
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('upload'),
it 'prefers thumbnail_base over an explicit upload' do
expect(Post).not_to receive(:resized_thumbnail_attachment)
allow(Post).to receive(:remote_thumbnail_attachment).and_return(
io: StringIO.new('remote'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
post = described_class.new(
actor:,
@@ -41,13 +41,12 @@ RSpec.describe PostCreator do
end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg') do |post, _url|
post.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
end
expect(Post).to receive(:remote_thumbnail_attachment)
.with('https://example.com/thumb.jpg')
.and_return(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
post = described_class.new(
actor:,
@@ -61,8 +60,8 @@ RSpec.describe PostCreator do
expect(post.thumbnail).to be_attached
end
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
it 'does not create a post when remote thumbnail fetch fails' do
allow(Post).to receive(:remote_thumbnail_attachment)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new(
actor:,
@@ -72,11 +71,8 @@ RSpec.describe PostCreator do
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' })
post = creator.create!
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
expect(post.thumbnail).not_to be_attached
expect(creator.field_warnings).to eq(
thumbnail_base: ['サムネール画像を取得できませんでした.'])
post_count = Post.count
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
expect(Post.count).to eq(post_count)
end
end
-77
ファイルの表示
@@ -1,77 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRowNormaliser do
def valid_row(overrides = { })
{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: '1' },
provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides)
end
describe '.normalise!' do
it 'normalises aliases and source rows into a permitted plain hash' do
row = ActionController::Parameters.new(valid_row)
expect(described_class.normalise!([row])).to eq([
{
'source_row' => 1,
'url' => 'https://example.com/post',
'metadata_url' => 'https://example.com/post',
'attributes' => { 'title' => 'title', 'duration' => '1' },
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
}
])
end
it 'normalises source rows before checking duplicates' do
rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')]
expect { described_class.normalise!(rows) }
.to raise_error(ArgumentError, '元行番号が重複しています.')
end
it 'rejects non-array batches and non-hash rows' do
expect { described_class.normalise!({}) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
expect { described_class.normalise!(['row']) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
end
it 'rejects unknown attributes and invalid field types' do
expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) }
.to raise_error(ArgumentError, '取込項目が不正です.')
expect { described_class.normalise!([valid_row(attributes: { title: [] })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
expect { described_class.normalise!([valid_row(attributes: { duration: false })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
end
it 'rejects unknown provenance and tag-source values' do
expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) }
.to raise_error(ArgumentError, '値の由来が不正です.')
expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) }
.to raise_error(ArgumentError, 'タグ由来の形式が不正です.')
end
it 'accepts warning fields only at the validation boundary' do
row = valid_row.merge(
fieldWarnings: { title: ['取得できませんでした.'] },
baseWarnings: ['確認してください.']
)
without_warnings = described_class.normalise!([row]).first
with_warnings = described_class.normalise!([row], allow_warning_fields: true).first
expect(without_warnings).not_to include('field_warnings', 'base_warnings')
expect(with_warnings).to include(
'field_warnings' => { 'title' => ['取得できませんでした.'] },
'base_warnings' => ['確認してください.']
)
end
end
end
-152
ファイルの表示
@@ -1,152 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRunner do
let(:actor) { create(:user, :member) }
def row(source_row: 1, url: 'https://example.com/post')
{
sourceRow: source_row,
url:,
attributes: { title: 'title', tags: '' },
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
tagSources: { automatic: '', manual: '' }
}
end
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
{
source_row:,
attributes: { 'title' => 'title', 'tags' => '' },
validation_errors: errors,
skip_reason:,
existing_post_id:
}
end
it 'previews the whole batch once before processing individual rows' do
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
expect(previewer).to receive(:preview_rows)
.with(
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
fetch_metadata: false
)
.and_return([preview, preview(source_row: 2)])
allow(PostCreator).to receive(:new).and_return(
instance_double(PostCreator, create!: create(:post))
)
result = described_class.new(actor:, rows:).run
expect(result).to include(created: 2, skipped: 0, failed: 0)
end
it 'treats validation errors as failures before an existing skip' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(errors: { url: ['URL が重複しています.'] },
skip_reason: 'existing', existing_post_id: existing.id)
])
expect(PostCreator).not_to receive(:new)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'failed',
errors: { url: ['URL が重複しています.'] },
recoverable: true
)
end
it 'returns the existing post ID for skipped rows' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(skip_reason: 'existing', existing_post_id: existing.id)
])
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to eq(
source_row: 1,
status: 'skipped',
existing_post_id: existing.id
)
end
it 'converts a URL uniqueness validation race into a skip' do
existing = create(:post, url: 'https://example.com/race')
invalid = Post.new(url: existing.url)
invalid.errors.add(:url, :taken)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!)
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
expect {
described_class.new(actor:, rows: [row]).run
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'converts a posts URL index race into a skip' do
existing = create(:post, url: 'https://example.com/index-race')
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique,
'duplicate key index_posts_on_url'
)
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 'returns thumbnail warnings from PostCreator on a created row' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
created_post = create(:post)
creator = instance_double(
PostCreator,
create!: created_post,
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
allow(PostCreator).to receive(:new).and_return(creator)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'created',
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
)
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportUrlListParser do
describe '.parse' do
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
expect(described_class.parse(source)).to eq([
{ source_row: 1, url: 'https://example.com/one' },
{ source_row: 3, url: 'https://example.com/two' }
])
end
it 'rejects an empty URL list' do
expect { described_class.parse(" \n\r\n") }
.to raise_error(ArgumentError, 'URL を入力してください.')
end
it 'rejects more than 100 non-empty rows' do
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
end
it 'includes the original line number in an oversized URL error' do
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
end
it 'rejects an oversized request before parsing rows' do
source = 'a' * (described_class::MAX_BYTES + 1)
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '入力が大きすぎます.')
end
end
end
+52
ファイルの表示
@@ -0,0 +1,52 @@
require 'rails_helper'
require 'base64'
require 'tempfile'
RSpec.describe PostThumbnailUploadValidator do
def with_upload bytes, content_type:, filename:
tempfile = Tempfile.new(['thumbnail-upload', File.extname(filename)])
tempfile.binmode
tempfile.write(bytes)
tempfile.rewind
upload = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: content_type)
yield upload
ensure
tempfile&.close!
end
it 'accepts a raster upload after decoding and rewinds it' do
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
with_upload(gif, content_type: 'image/gif', filename: 'thumbnail.gif') do |upload|
expect { described_class.validate!(upload) }.not_to raise_error
expect(upload.read(6)).to eq('GIF87a')
end
end
it 'rejects SVG content disguised as a raster MIME type' do
with_upload(
'<svg width="10" height="10"></svg>',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
it 'rejects non-raster content disguised as an image' do
with_upload(
'%PDF-1.7',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
end
+11 -10
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
it 'rejects svg thumbnails' do
it 'accepts svg thumbnails for the common safe rasterisation path' do
page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html',
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml',
'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg')
.and_return(svg)
expect {
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
end
it 'accepts allowed image content type with parameters' do
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
'jpeg-bytes',
"\xFF\xD8\xFFjpeg-bytes".b,
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate)
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -45,7 +45,8 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg')
.and_return(image)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
expect(described_class.fetch('https://example.com/page'))
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
end
end
+4
ファイルの表示
@@ -268,6 +268,10 @@ RSpec.describe Youtube::Sync do
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostTag.exists?(post:, tag: Tag.no_deerjikist)).to be(false)
expect(Tag.no_deerjikist.reload.post_count).to eq(0)
expect(deerjikist_tag.reload.post_count).to eq(1)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
+203 -51
ファイルの表示
@@ -1,36 +1,35 @@
require "rails_helper"
require 'rails_helper'
RSpec.describe "nico:sync" do
RSpec.describe 'nico:sync' do
def stub_python(json_array)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status])
allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
end
def create_tag!(name, category:)
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) { |t| t.category = category }
Tag.find_or_create_by_tag_name!(name, category:)
end
def link_nico_to_tag!(nico_tag, tag)
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
end
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
# 既存 post(正規表現で拾われるURL)
post = Post.create!(
title: "old",
url: "https://www.nicovideo.jp/watch/sm9",
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
# 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general")
kept_general = create_tag!('spec_kept', category: 'general')
PostTag.create!(post: post, tag: kept_general)
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!("spec_linked", category: "general")
nico = create_tag!("nico:AAA", category: "nico")
linked = create_tag!('spec_linked', category: 'general')
nico = create_external_tag!('AAA')
link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -46,22 +45,22 @@ RSpec.describe "nico:sync" do
'deleted_at' => '2026-01-31 00:00:00' }])
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync")
run_rake_task('nico:sync')
post.reload
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name")
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include("spec_kept")
expect(active_tag_names).to include("nico:AAA")
expect(active_tag_names).to include("spec_linked")
expect(active_tag_names).to include('spec_kept')
expect(post.external_tags).to contain_exactly(nico)
expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
expect(active_tag_names).to include("bot操作")
expect(active_tag_names).to include('bot操作')
end
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
@@ -73,11 +72,11 @@ RSpec.describe "nico:sync" do
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open)
.and_return(
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
expect(post).to receive(:attach_thumbnail_from_url!)
allow(URI).to receive(:open) do
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
end
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
run_rake_task('nico:sync')
@@ -92,53 +91,61 @@ RSpec.describe "nico:sync" do
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open)
.and_return(
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
expect(post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
.twice
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
allow(URI).to receive(:open) do
StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
end
calls = 0
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
calls += 1
raise Post::RemoteThumbnailFetchFailed, 'failed'
end
2.times do
run_rake_task('nico:sync')
end
expect(calls).to eq(2)
end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
it '古い nico tag の関連を物理削除し、変更前後の履歴を version に残す' do
post = Post.create!(
title: "old",
url: "https://www.nicovideo.jp/watch/sm9",
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
# 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "nico")
old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil
old_nico = create_external_tag!('OLD')
PostExternalTag.create!(post:, external_tag: old_nico)
create_post_version_for!(post)
# 今回は NEW のみ欲しい
new_nico = create_tag!("nico:NEW", category: "nico")
new_nico = create_external_tag!('NEW')
# bot/tagme 念のため
Tag.bot
Tag.tagme
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync")
run_rake_task('nico:sync')
# OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
expect(old_pts.last.discarded_at).to be_present
expect(PostExternalTag.exists?(post:, external_tag: old_nico)).to be(false)
expect(old_nico.reload.post_count).to eq(0)
expect(new_nico.reload.post_count).to eq(1)
versions = post.post_versions.order(:version_no)
expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(old_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(new_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.not_to include(old_nico.id)
# NEW は active にいる
post.reload
active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
expect(active_names).to include("nico:NEW")
expect(active_names).not_to include("nico:OLD")
expect(post.external_tags).to contain_exactly(new_nico)
end
def snapshot_tags(post)
@@ -154,6 +161,7 @@ RSpec.describe "nico:sync" do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
@@ -203,7 +211,7 @@ RSpec.describe "nico:sync" do
create_post_version_for!(post)
linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!('nico:AAA', category: 'nico')
nico = create_external_tag!('AAA')
link_nico_to_tag!(nico, linked)
Tag.bot
@@ -231,7 +239,7 @@ RSpec.describe "nico:sync" do
end
it '既存 post に差分が無いときは新しい version を作らない' do
nico = create_tag!('nico:AAA', category: 'nico')
nico = create_external_tag!('AAA')
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
post = Post.create!(
@@ -242,7 +250,7 @@ RSpec.describe "nico:sync" do
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
)
PostTag.create!(post: post, tag: nico)
PostExternalTag.create!(post:, external_tag: nico)
PostTag.create!(post: post, tag: no_deerjikist)
create_post_version_for!(post)
@@ -285,7 +293,7 @@ RSpec.describe "nico:sync" do
run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1)
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA')
version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
@@ -368,4 +376,148 @@ RSpec.describe "nico:sync" do
expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end
def create_external_tag!(name)
ExternalTag.create!(platform: :nico, name:)
end
def create_nico_sync_post!
post = Post.create!(
title: 't',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
PostTag.create!(post:, tag: Tag.no_deerjikist)
post
end
def run_nico_sync_with_tags! tags
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => tags,
'user' => nil
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task('nico:sync')
end
it '外部タグだけの変更では bot を付けず,投稿履歴を記録する' do
post = create_nico_sync_post!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
expect {
run_nico_sync_with_tags!(['raw tag[]', 'raw tag[]'])
}.to change(PostVersion, :count).by(1)
.and change(ExternalTag, :count).by(1)
.and change(PostExternalTag, :count).by(1)
.and change(NicoTagVersion, :count).by(1)
.and change(TagName, :count).by(0)
external = post.external_tags.sole
expect(external.name).to eq('raw tag[]')
expect(post.tags.map(&:name)).not_to include('bot操作')
expect(post.post_versions.order(:version_no).last.tags_json)
.to include('external_tag_id' => external.id)
expect {
run_nico_sync_with_tags!(['raw tag[]'])
}.to change(PostVersion, :count).by(0).and change(NicoTagVersion, :count).by(0)
expect {
run_nico_sync_with_tags!([])
}.to change(PostVersion, :count).by(1)
expect(post.reload.external_tags).to be_empty
expect(post.tags.map(&:name)).not_to include('bot操作')
end
it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
linked_tag = create_tag!('spec_linked', category: :general)
link_nico_to_tag!(external_tag, linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.external_tags).to include(external_tag)
expect(post.tags).to include(linked_tag)
end
it '外部タグに差分がない場合,内外マッピングが変はっても連携タグを再評価しない' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.tags).to include(old_linked_tag)
# 人手で連携タグを消除する.
PostTag.find_by!(post:, tag: old_linked_tag).destroy!
# 内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 外部タグ自体には差分が無い.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).not_to include(old_linked_tag)
expect(post.tags).not_to include(new_linked_tag)
end
it '外部タグの消除では連携タグを消除せず,再記載時にその時点の内外マッピングを適用する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
# 外部タグを新規記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).to include(old_linked_tag)
# 外部タグを消除する.
run_nico_sync_with_tags!([])
post.reload
expect(post.external_tags).not_to include(external_tag)
# 外部タグの消除によって連携タグまでは消除されない.
expect(post.tags).to include(old_linked_tag)
# 外部タグが記載されてゐない間に内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 同じ外部タグを再記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
# 旧連携タグは自動的には消除されない.
expect(post.tags).to include(old_linked_tag)
# 再記載時点の内外マッピングが新たに適用される.
expect(post.tags).to include(new_linked_tag)
end
end
+9
ファイルの表示
@@ -153,6 +153,9 @@ pass or the remaining failure is clearly blocked.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not use `タグなし` as user-facing copy for an empty tag state. When the
tag state is empty, show no copy. If actual data contains the tag name
`タグなし`, treat it as ordinary data and display it normally.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
@@ -599,6 +602,12 @@ offsets, or footer offsets, inspect existing layout components such as
Do not create a second layout shell before checking whether the current layout
can be reused or minimally extended.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm``md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
+42 -37
ファイルの表示
@@ -1,8 +1,9 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
import { createBrowserRouter,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
@@ -154,7 +155,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const App: FC = () => {
const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
@@ -253,42 +254,46 @@ const App: FC = () => {
}
return (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<BrowserRouter>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>)
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const App: FC = () => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
</>)
export default App
+27 -2
ファイルの表示
@@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
const renderRow = (
activeDndId?: string,
renderedTag = tag,
) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
tag={renderedTag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
@@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
it ('disables drag and drop for external tags', () => {
const external = buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
})
renderRow (undefined, external)
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
expect (dndKit.useDroppable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
})
})
+9 -5
ファイルの表示
@@ -78,19 +78,23 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
const post = buildPost ()
api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({
@@ -112,5 +116,5 @@ describe ('PostEditForm', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
})
+1 -1
ファイルの表示
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getDisplayValue ('2024-01-01T12:34')
const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
+6 -6
ファイルの表示
@@ -24,10 +24,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="w-full"
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
@@ -61,10 +61,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="w-full"
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
+66
ファイルの表示
@@ -0,0 +1,66 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import { setClientTagRelationDisplayMode } from '@/lib/settings'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
vi.mock ('@/components/TagSearch', () => ({
default: () => null,
}))
vi.mock ('@/components/DraggableDroppableTagRow', () => ({
default: ({ tag }: { tag: { name: string } }) => (
<span>{tag.name}</span>
),
}))
vi.mock ('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: 'always' },
MouseSensor: vi.fn (),
TouchSensor: vi.fn (),
pointerWithin: vi.fn (),
useDroppable: vi.fn (() => ({
setNodeRef: vi.fn (),
isOver: false,
})),
useSensor: vi.fn (() => ({ })),
useSensors: vi.fn (() => []),
}))
describe ('TagDetailSidebar', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('keeps internal and external tags with the same numeric id in flat mode', () => {
setClientTagRelationDisplayMode ('flat')
const internal = buildTag ({
id: 7,
name: 'internal_collision',
category: 'general',
})
const external = buildTag ({
id: 7,
name: 'nico:external_collision',
category: 'nico',
})
renderWithProviders (
<TagDetailSidebar
post={buildPost ({
tags: [internal, external],
})}/>,
)
expect (screen.getByText ('internal_collision')).toBeInTheDocument ()
expect (screen.getByText ('nico:external_collision')).toBeInTheDocument ()
})
})
+27
ファイルの表示
@@ -57,4 +57,31 @@ describe ('TagLink', () => {
expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
})
it ('does not show a missing-information marker for external tags', () => {
renderWithProviders (
<TagLink
tag={buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
})}
withCount={false}/>,
)
expect (
screen.getByRole ('link', { name: 'nico:external' }),
).toBeInTheDocument ()
expect (
screen.queryByRole ('link', { name: '!' }),
).not.toBeInTheDocument ()
expect (
screen.queryByTitle ('nico:external Wiki が存在しません.'),
).not.toBeInTheDocument ()
})
})
+43 -12
ファイルの表示
@@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
import type { Category, Tag } from '@/types'
type CommonProps = {
type LightweightTag = {
name: string
category: Category }
type FullCommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
@@ -14,18 +18,43 @@ type CommonProps = {
withCount?: boolean }
type PropsWithLink =
& CommonProps
& FullCommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink =
& CommonProps
& FullCommonProps
& { linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type LightweightPropsWithLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type LightweightPropsWithoutLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type Props =
| PropsWithLink
| PropsWithoutLink
| LightweightPropsWithLink
| LightweightPropsWithoutLink
const isFullTag = (tag: Tag | LightweightTag): tag is Tag =>
'id' in tag
const TagLink: FC<Props> = ({ tag,
@@ -46,16 +75,18 @@ const TagLink: FC<Props> = ({ tag,
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return (
<span className={rootClass}>
{(linkFlg && withWiki) && (
<span
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
@@ -118,7 +149,7 @@ const TagLink: FC<Props> = ({ tag,
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{tag.matchedAlias != null && (
{matchedAlias != null && (
<>
<span
title={textTitle}
@@ -126,7 +157,7 @@ const TagLink: FC<Props> = ({ tag,
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
text={matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
@@ -156,7 +187,7 @@ const TagLink: FC<Props> = ({ tag,
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{withCount && (
{(withCount && isFullTag (tag)) && (
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
+2 -4
ファイルの表示
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
for (const role of ['member', 'admin'] as const)
{
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
}
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
it ('uses /posts/new as the post creation entrypoint', () => {
expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
+3 -1
ファイルの表示
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
'w-full',
'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
+6 -6
ファイルの表示
@@ -215,12 +215,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogFooter
className="shrink-0 flex-col gap-2 pt-4
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
@@ -232,9 +232,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</Button>))}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant="outline"
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
@@ -245,7 +245,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
{endActions.map (action => (
<Button
key={action.label}
className="w-full sm:w-auto"
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
+6 -2
ファイルの表示
@@ -17,13 +17,15 @@ type Props = {
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps }
core: PostCoreDataFieldsProps
extraFields?: ReactNode }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core },
core,
extraFields },
) => (
<>
<PostTextField
@@ -39,6 +41,8 @@ const PostCreationDataFields: FC<Props> = (
{thumbnailField}
<PostCoreDataFields {...core}/>
{extraFields}
</>)
export default PostCreationDataFields

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