コミットを比較
98
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
2f87669699 | ||
|
|
1c906d7432 | ||
|
|
f662dc9dc0 | ||
|
|
206c6bc0a0 | ||
|
|
8f66ee8059 | ||
|
|
83e3db3314 | ||
|
|
eae6c30064 | ||
|
|
240e078f0b | ||
|
|
74b1ada0dd | ||
|
|
23d8adf65d | ||
|
|
e0debed94e | ||
|
|
583ce22a7e | ||
|
|
7c808a6f76 | ||
|
|
a5ae7c6f2d | ||
|
|
03dc4d0661 | ||
|
|
06b9c1cb50 | ||
|
|
688b4af575 | ||
|
|
040cc3f25d | ||
|
|
b3e67d8cca | ||
|
|
b00a62a0ac | ||
|
|
e6c3c635b8 | ||
|
|
9552081133 | ||
|
|
2d2a9b4bd6 | ||
|
|
09ac2576bb | ||
|
|
9eca670934 | ||
|
|
c31d84115d | ||
|
|
ff970f8171 | ||
|
|
0ae41b6266 | ||
|
|
eb4bf5e35c | ||
|
|
e6b7e33b83 | ||
|
|
3820d3d4d5 | ||
|
|
dd2d199d04 | ||
|
|
0a8ffc38b8 | ||
|
|
3f75994bd4 | ||
|
|
f91b78bd47 | ||
|
|
7f8cce39bc | ||
|
|
5c5a9fa1b0 | ||
|
|
66c738cbff | ||
|
|
cde0a2deae | ||
|
|
43a3772976 | ||
|
|
90d8d3ff08 | ||
|
|
d1de631eed | ||
|
|
6f3ccb0ffa | ||
|
|
95c3f08a44 | ||
|
|
ef95b20a7e | ||
|
|
f76fbe6711 | ||
|
|
0224c4d2f4 | ||
|
|
ead825e3ee | ||
|
|
3d88fdecb1 | ||
|
|
2e1b4449ba | ||
|
|
4eb483c049 | ||
|
|
e197f27e9a | ||
|
|
34f81325d2 | ||
|
|
9af4670c6c | ||
|
|
5183b0b354 | ||
|
|
d0914fce3b | ||
|
|
0ac7332458 | ||
|
|
07ce19e32d | ||
|
|
5f1d619139 | ||
|
|
3cd22855b3 | ||
|
|
55c69866a7 | ||
|
|
04d2f0f5a9 | ||
|
|
025f49cbcb | ||
|
|
d2f1b1ed48 | ||
|
|
6e5aa1e30f | ||
|
|
21d6c1408a | ||
|
|
eebbd5d069 | ||
|
|
58828597d7 | ||
|
|
b0c24f319a | ||
|
|
f9463f383f | ||
|
|
6d037192c4 | ||
|
|
d035da99ad | ||
|
|
bc660676ef | ||
|
|
97b132e5ab | ||
|
|
be68841bd3 | ||
|
|
ac82adc6b3 | ||
|
|
aa96ec95d1 | ||
|
|
df17f20907 | ||
|
|
769966648b | ||
|
|
7bcb76516c | ||
|
|
8970edc59f | ||
|
|
9b1ea56e36 | ||
|
|
155edfe018 | ||
|
|
23f1ffb04b | ||
|
|
ccfe65a6a4 | ||
|
|
06d6e512e4 | ||
|
|
5bd097bcfe | ||
|
|
f636d2a177 | ||
|
|
10dc776313 | ||
|
|
a9e16735f8 | ||
|
|
440a6c9961 | ||
|
|
4535a9d260 | ||
|
|
440d3d38be | ||
|
|
cb33d9ca25 | ||
|
|
19bf24432a | ||
|
|
ac41385962 | ||
|
|
08bf92ff79 | ||
|
|
c51d7b98ba |
@@ -124,7 +124,7 @@ npm run preview
|
|||||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||||
- Keep the first element on the same line as `[` by default.
|
- Keep the first element on the same line as `[` by default.
|
||||||
- If an array would exceed the line limit, break after `[` and indent
|
- If an array would exceed the line limit, break after `[` and indent
|
||||||
elements 4 spaces deeper than the statement's base indentation.
|
elements by 4 spaces.
|
||||||
|
|
||||||
### Ruby delimiter and wrapping rules
|
### Ruby delimiter and wrapping rules
|
||||||
|
|
||||||
@@ -140,32 +140,6 @@ npm run preview
|
|||||||
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
||||||
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
||||||
block close のやうに独立させない。
|
block close のやうに独立させない。
|
||||||
- Ruby では、行末の `\` を用途を問はず一切使用しない。
|
|
||||||
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
|
|
||||||
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
|
|
||||||
- Ruby の block body は、その基準位置から 2 空白深くする。
|
|
||||||
- Ruby の wrapped expression、method argument、array element、hash pair などの
|
|
||||||
continuation indentation は、その statement の基準位置から 4 空白深くする。
|
|
||||||
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
|
|
||||||
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
|
|
||||||
括弧内ではバックスラッシュなしで改行する。
|
|
||||||
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
|
|
||||||
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
|
|
||||||
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
|
|
||||||
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
|
|
||||||
method へ抽出する、のいづれかへ書き換へる。
|
|
||||||
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
|
|
||||||
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
|
|
||||||
並べない。
|
|
||||||
- RSpec の `describe`、`context`、`it` 等の description が長い場合は、意味を
|
|
||||||
保ったまま一行へ収まる文言へ短縮する。
|
|
||||||
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
|
|
||||||
検討する。
|
|
||||||
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
|
|
||||||
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
|
|
||||||
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
|
|
||||||
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
|
|
||||||
深くするため、結果として行頭から 6 空白になる。
|
|
||||||
|
|
||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
@@ -217,63 +191,6 @@ end
|
|||||||
|
|
||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
```rb
|
|
||||||
Rails.logger.info(
|
|
||||||
"post_import_metadata_fetch_failure "\
|
|
||||||
"#{ payload.to_json }")
|
|
||||||
```
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
payload = {
|
|
||||||
error: e.class.name,
|
|
||||||
message: e.message }
|
|
||||||
|
|
||||||
Rails.logger.info(
|
|
||||||
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
|
||||||
```
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
message = "first "\
|
|
||||||
"second"
|
|
||||||
```
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
message = format(
|
|
||||||
'%<first>s %<second>s',
|
|
||||||
first: 'first',
|
|
||||||
second: 'second')
|
|
||||||
```
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
result = first_value + \
|
|
||||||
second_value
|
|
||||||
```
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
it(
|
|
||||||
'returns 409 when stale changes '
|
|
||||||
'do not conflict'
|
|
||||||
) do
|
|
||||||
```
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
it 'returns mergeable 409 for stale non-conflicting changes' do
|
|
||||||
```
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
```rb
|
```rb
|
||||||
records.each {
|
records.each {
|
||||||
do_work(_1) }
|
do_work(_1) }
|
||||||
@@ -308,37 +225,20 @@ records.each {
|
|||||||
formatting as the local reference shape: component and callback bodies use
|
formatting as the local reference shape: component and callback bodies use
|
||||||
2-space block indentation, single-line bodies do not gain unnecessary
|
2-space block indentation, single-line bodies do not gain unnecessary
|
||||||
braces, wrapped expressions use 4-space continuation indentation, multi-
|
braces, wrapped expressions use 4-space continuation indentation, multi-
|
||||||
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
|
stage ternaries use explicit parentheses, and only complete leading runs
|
||||||
and alignment whitespace compress every complete run of 8 spaces to tabs.
|
of 8 spaces are compressed to tabs.
|
||||||
|
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
|
||||||
- A tab does not represent one indentation level.
|
- A tab does not represent one indentation level.
|
||||||
- In TypeScript and TSX, first determine visible indentation using 2-space
|
- Do not replace 2-space or 4-space indentation with tabs.
|
||||||
block indentation and 4-space continuation indentation, then compress every
|
- First determine visible indentation using 2-space block indentation and
|
||||||
complete run of 8 spaces used for indentation or column alignment to tabs.
|
4-space continuation indentation, then compress only complete leading runs
|
||||||
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
|
of 8 spaces into tabs.
|
||||||
- In TypeScript and TSX, this applies both to leading indentation and to
|
- Tabs are only for leading indentation, never for spaces after non-space
|
||||||
alignment whitespace after non-space text, such as aligned inline type
|
text.
|
||||||
columns.
|
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
|
||||||
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
|
|
||||||
compression.
|
|
||||||
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
|
|
||||||
- In TypeScript and TSX, do not alter string literals, template-literal
|
|
||||||
contents, regular expressions, or user-facing text merely to apply tab
|
|
||||||
compression.
|
|
||||||
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
|
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
|
||||||
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
|
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
|
||||||
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
|
tab + 4 spaces, 16 columns = 2 tabs.
|
||||||
- When TypeScript or TSX code already uses column alignment, apply the same
|
|
||||||
8-space compression rule to that alignment whitespace.
|
|
||||||
- Example:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type Props = {
|
|
||||||
row: PostImportRow
|
|
||||||
displayNumber?: number
|
|
||||||
onEdit?: () => void
|
|
||||||
onRetry?: () => void
|
|
||||||
onToggleSkip?: (checked: boolean) => void }
|
|
||||||
```
|
|
||||||
- TypeScript and TSX imports may stay on one line if they remain within the
|
- TypeScript and TSX imports may stay on one line if they remain within the
|
||||||
line limit; do not expand short type-only imports mechanically.
|
line limit; do not expand short type-only imports mechanically.
|
||||||
- Keep runtime value imports and type imports in separate declarations.
|
- Keep runtime value imports and type imports in separate declarations.
|
||||||
@@ -574,32 +474,8 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
wording and placement before implementing it.
|
wording and placement before implementing it.
|
||||||
- Do not invent replacement copy when removing unrequested wording.
|
- Do not invent replacement copy when removing unrequested wording.
|
||||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||||
test work. When the user asks for tests, keep working within the permitted
|
test work. When the user asks for tests, keep working and rerun them until
|
||||||
test-file scope and rerun them until they pass or the remaining failure is
|
they pass or the remaining failure is clearly blocked.
|
||||||
clearly blocked.
|
|
||||||
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
|
||||||
SyntaxError in tests. During test-only work, do not modify production code.
|
|
||||||
- During test-only work, do not change production constants, behaviour, API
|
|
||||||
contracts, validation, routes, authentication, permissions, UI, copy,
|
|
||||||
dependencies, limits, thresholds, defaults, migrations, schema, or
|
|
||||||
environment settings to satisfy tests.
|
|
||||||
- Do not make production code match failing tests, mock assumptions, fixtures,
|
|
||||||
snapshots, old expectations, or stale setup. This includes changing
|
|
||||||
production constants merely because a test expects a different value.
|
|
||||||
- If test work reveals a production bug, spec mismatch, or missing behaviour,
|
|
||||||
stop without modifying production code and report: the failing test or
|
|
||||||
discovered issue, the related production file, the actual behaviour, the
|
|
||||||
expected behaviour, and why a production change appears necessary.
|
|
||||||
- Modify production code for test failures only when the user explicitly asks
|
|
||||||
for that production change. Do not expand a test task into a production task
|
|
||||||
on your own authority.
|
|
||||||
- If the user explicitly asks for both production implementation and test
|
|
||||||
updates, implement production code to the confirmed specification first,
|
|
||||||
then add or update tests to verify that specification. Never roll production
|
|
||||||
behaviour back to satisfy old tests.
|
|
||||||
- If it is unclear whether the test or the production implementation is stale,
|
|
||||||
or a test cannot be corrected without changing production code, ask the user
|
|
||||||
instead of guessing.
|
|
||||||
|
|
||||||
## Backend rules
|
## Backend rules
|
||||||
|
|
||||||
@@ -722,10 +598,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
beginning of a line.
|
beginning of a line.
|
||||||
- The TSX-specific self-review must confirm JSX closing markers and closing
|
- The TSX-specific self-review must confirm JSX closing markers and closing
|
||||||
parentheses keep the surrounding compact style.
|
parentheses keep the surrounding compact style.
|
||||||
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
|
- The TypeScript/TSX self-review must confirm leading block indentation uses
|
||||||
per level, wrapped continuations use the repository's 4-space continuation
|
2 spaces per level, wrapped continuations use the repository's 4-space
|
||||||
alignment, and every complete run of 8 spaces used for indentation or
|
continuation alignment, and complete leading runs of 8 spaces may be
|
||||||
alignment has been compressed to tabs.
|
compressed to tabs.
|
||||||
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
||||||
- Put two blank lines before and after top-level `const` function
|
- Put two blank lines before and after top-level `const` function
|
||||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||||
@@ -738,18 +614,9 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||||
- Do not write `try {`, `catch {`, or `finally {`.
|
- Do not write `try {`, `catch {`, or `finally {`.
|
||||||
- In TypeScript and TSX, convert every complete run of 8 spaces used for
|
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
|
||||||
indentation or alignment to a tab character.
|
tab character.
|
||||||
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
|
- A leading tab is exactly equivalent to 8 leading spaces.
|
||||||
appears at the beginning of a line or in alignment whitespace after
|
|
||||||
non-space text.
|
|
||||||
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
|
|
||||||
attributes use 4-space continuation indentation; after visible columns are
|
|
||||||
determined, compress every complete run of 8 spaces in the resulting
|
|
||||||
indentation or alignment to tabs.
|
|
||||||
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
|
|
||||||
columns as spaces alone; convert each complete run of 8 spaces to tabs and
|
|
||||||
keep only the residual 2, 4, or 6 spaces.
|
|
||||||
- In TypeScript and TSX function declarations, including `const` arrow
|
- In TypeScript and TSX function declarations, including `const` arrow
|
||||||
function declarations, classify the parameter list before placing the closing
|
function declarations, classify the parameter list before placing the closing
|
||||||
`)`.
|
`)`.
|
||||||
@@ -1392,9 +1259,9 @@ to `.ts` and `.tsx`:
|
|||||||
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
|
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
|
||||||
otherwise.
|
otherwise.
|
||||||
8. JSX closing parentheses keep the compact local style.
|
8. JSX closing parentheses keep the compact local style.
|
||||||
9. Block indentation uses 2 spaces per level, wrapped continuations use the
|
9. Leading block indentation uses 2 spaces per level, wrapped continuations
|
||||||
repository's 4-space continuation alignment, and every complete run of 8
|
use the repository's 4-space continuation alignment, and complete leading
|
||||||
spaces used for indentation or alignment has been compressed to tabs.
|
runs of 8 spaces may be compressed to tabs.
|
||||||
10. No line has trailing whitespace.
|
10. No line has trailing whitespace.
|
||||||
|
|
||||||
Preferred:
|
Preferred:
|
||||||
|
|||||||
@@ -85,11 +85,4 @@ class ApplicationController < ActionController::API
|
|||||||
base_errors: },
|
base_errors: },
|
||||||
status:
|
status:
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_json value
|
|
||||||
return nil if value.nil?
|
|
||||||
return JSON.parse(value) if value.is_a?(String)
|
|
||||||
|
|
||||||
value
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class PostVersionsController < ApplicationController
|
class PostVersionsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
post_id = params[:post].presence
|
post_id = params[:post].presence
|
||||||
tag_id = params[:tag].presence&.to_i
|
tag_id = params[:tag].presence
|
||||||
page = (params[:page].presence || 1).to_i
|
page = (params[:page].presence || 1).to_i
|
||||||
limit = (params[:limit].presence || 20).to_i
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
@@ -10,6 +10,12 @@ class PostVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
tag_name =
|
||||||
|
if tag_id
|
||||||
|
TagName.joins(:tag).find_by(tag: { id: tag_id })
|
||||||
|
end
|
||||||
|
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
|
||||||
|
|
||||||
q = PostVersion.joins(<<~SQL.squish)
|
q = PostVersion.joins(<<~SQL.squish)
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
post_versions prev
|
post_versions prev
|
||||||
@@ -17,18 +23,17 @@ class PostVersionsController < ApplicationController
|
|||||||
prev.post_id = post_versions.post_id
|
prev.post_id = post_versions.post_id
|
||||||
AND prev.version_no = post_versions.version_no - 1
|
AND prev.version_no = post_versions.version_no - 1
|
||||||
SQL
|
SQL
|
||||||
.select('post_versions.*',
|
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
|
||||||
'prev.title AS prev_title',
|
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
|
||||||
'prev.url AS prev_url',
|
|
||||||
'prev.thumbnail_base AS prev_thumbnail_base',
|
|
||||||
'prev.tags_json AS prev_tags_json',
|
|
||||||
'prev.video_ms AS prev_video_ms',
|
'prev.video_ms AS prev_video_ms',
|
||||||
'prev.original_created_from AS prev_original_created_from',
|
'prev.original_created_from AS prev_original_created_from',
|
||||||
'prev.original_created_before AS prev_original_created_before')
|
'prev.original_created_before AS prev_original_created_before')
|
||||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||||
if tag_id
|
if tag_name
|
||||||
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " +
|
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
|
||||||
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
|
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
|
||||||
|
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
|
||||||
|
kw: "% #{ escaped } %")
|
||||||
end
|
end
|
||||||
|
|
||||||
count = q.except(:select, :order, :limit, :offset).count
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
@@ -47,69 +52,51 @@ class PostVersionsController < ApplicationController
|
|||||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
|
||||||
rows.map do |row|
|
rows.map do |row|
|
||||||
cur_tags =
|
cur_tags = split_tags(row.tags)
|
||||||
normalise_json(row.tags_json)
|
prev_tags = split_tags(row.attributes['prev_tags'])
|
||||||
.sort_by { [(case _1.fetch('category')
|
|
||||||
when 'deerjikist'
|
|
||||||
0
|
|
||||||
when 'meme'
|
|
||||||
1
|
|
||||||
when 'character'
|
|
||||||
2
|
|
||||||
when 'general'
|
|
||||||
3
|
|
||||||
when 'material'
|
|
||||||
4
|
|
||||||
when 'meta'
|
|
||||||
5
|
|
||||||
else
|
|
||||||
6
|
|
||||||
end),
|
|
||||||
_1.fetch('name').downcase] }
|
|
||||||
.map { Post.tag_snapshot_literal(_1) }
|
|
||||||
prev_tags =
|
|
||||||
(normalise_json(row.attributes['prev_tags_json']) || [])
|
|
||||||
.sort_by { [(case _1.fetch('category')
|
|
||||||
when 'deerjikist'
|
|
||||||
0
|
|
||||||
when 'meme'
|
|
||||||
1
|
|
||||||
when 'character'
|
|
||||||
2
|
|
||||||
when 'general'
|
|
||||||
3
|
|
||||||
when 'material'
|
|
||||||
4
|
|
||||||
when 'meta'
|
|
||||||
5
|
|
||||||
else
|
|
||||||
6
|
|
||||||
end),
|
|
||||||
_1.fetch('name').downcase] }
|
|
||||||
.map { Post.tag_snapshot_literal(_1) }
|
|
||||||
|
|
||||||
{ post_id: row.post_id,
|
{
|
||||||
|
post_id: row.post_id,
|
||||||
version_no: row.version_no,
|
version_no: row.version_no,
|
||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
title: { current: row.title, prev: row.attributes['prev_title'] },
|
title: {
|
||||||
url: { current: row.url, prev: row.attributes['prev_url'] },
|
current: row.title,
|
||||||
thumbnail: { current: nil, prev: nil },
|
prev: row.attributes['prev_title']
|
||||||
thumbnail_base: { current: row.thumbnail_base,
|
},
|
||||||
prev: row.attributes['prev_thumbnail_base'] },
|
url: {
|
||||||
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
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),
|
tags: build_version_tags(cur_tags, prev_tags),
|
||||||
original_created_from: {
|
original_created_from: {
|
||||||
current: row.original_created_from&.iso8601,
|
current: row.original_created_from&.iso8601,
|
||||||
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
prev: row.attributes['prev_original_created_from']&.iso8601
|
||||||
|
},
|
||||||
original_created_before: {
|
original_created_before: {
|
||||||
current: row.original_created_before&.iso8601,
|
current: row.original_created_before&.iso8601,
|
||||||
prev: row.attributes['prev_original_created_before']&.iso8601 },
|
prev: row.attributes['prev_original_created_before']&.iso8601
|
||||||
|
},
|
||||||
created_at: row.created_at.iso8601,
|
created_at: row.created_at.iso8601,
|
||||||
created_by_user:
|
created_by_user:
|
||||||
if row.created_by_user_id
|
if row.created_by_user_id
|
||||||
{ id: row.created_by_user_id,
|
{
|
||||||
name: users_by_id[row.created_by_user_id] }
|
id: row.created_by_user_id,
|
||||||
end }
|
name: users_by_id[row.created_by_user_id]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,4 +117,8 @@ class PostVersionsController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def split_tags(tags)
|
||||||
|
tags.to_s.split(/\s+/).reject(&:blank?)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class PostsController < ApplicationController
|
|||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
pt_max_sql =
|
pt_max_sql =
|
||||||
PostVersion
|
PostTag
|
||||||
.select('post_id, MAX(created_at) AS max_updated_at')
|
.select('post_id, MAX(updated_at) AS max_updated_at')
|
||||||
.group('post_id')
|
.group('post_id')
|
||||||
.to_sql
|
.to_sql
|
||||||
|
|
||||||
@@ -50,7 +50,8 @@ class PostsController < ApplicationController
|
|||||||
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
||||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||||
.preload(:uploaded_user, :parents, :children,
|
.preload(:uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
active_post_tags: [:sections,
|
||||||
|
{ tag: [:deerjikists, :materials,
|
||||||
{ tag_name: :wiki_page }] }])
|
{ tag_name: :wiki_page }] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
|
|
||||||
@@ -103,7 +104,8 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
def random
|
def random
|
||||||
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
active_post_tags: [:sections,
|
||||||
|
{ tag: [:deerjikists, :materials,
|
||||||
{ tag_name: :wiki_page }] }])
|
{ tag_name: :wiki_page }] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.order('RAND()')
|
.order('RAND()')
|
||||||
@@ -188,7 +190,8 @@ class PostsController < ApplicationController
|
|||||||
post =
|
post =
|
||||||
Post
|
Post
|
||||||
.includes(:uploaded_user, :parents, :children,
|
.includes(:uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
active_post_tags: [:sections,
|
||||||
|
{ tag: [:deerjikists, :materials,
|
||||||
{ tag_name: :wiki_page }] }])
|
{ tag_name: :wiki_page }] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
@@ -381,6 +384,50 @@ class PostsController < ApplicationController
|
|||||||
render_post_form_record_invalid e.record
|
render_post_form_record_invalid e.record
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def changes
|
||||||
|
id = params[:id].presence
|
||||||
|
tag_id = params[:tag].presence
|
||||||
|
page = (params[:page].presence || 1).to_i
|
||||||
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
|
page = 1 if page < 1
|
||||||
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
pts = PostTag.with_discarded
|
||||||
|
pts = pts.where(post_id: id) if id.present?
|
||||||
|
pts = pts.where(tag_id:) if tag_id.present?
|
||||||
|
pts = pts.includes(:post, :created_user, :deleted_user,
|
||||||
|
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
|
|
||||||
|
events = []
|
||||||
|
pts.each do |pt|
|
||||||
|
tag = TagRepr.base(pt.tag)
|
||||||
|
post = pt.post
|
||||||
|
|
||||||
|
events << Event.new(
|
||||||
|
post:,
|
||||||
|
tag:,
|
||||||
|
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
|
||||||
|
change_type: 'add',
|
||||||
|
timestamp: pt.created_at)
|
||||||
|
|
||||||
|
if pt.discarded_at
|
||||||
|
events << Event.new(
|
||||||
|
post:,
|
||||||
|
tag:,
|
||||||
|
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
|
||||||
|
change_type: 'remove',
|
||||||
|
timestamp: pt.discarded_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
events.sort_by!(&:timestamp)
|
||||||
|
events.reverse!
|
||||||
|
|
||||||
|
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_posts
|
def filtered_posts
|
||||||
@@ -455,13 +502,13 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
||||||
pt.destroy!
|
pt.discard_by!(current_user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_tree_for post
|
def build_tag_tree_for post
|
||||||
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
|
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
|
||||||
tags = post_tags.map(&:tag)
|
tags = post_tags.map(&:tag)
|
||||||
tag_ids = tags.map(&:id)
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
@@ -652,10 +699,7 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def editable_tag_names_from_version version
|
def editable_tag_names_from_version version
|
||||||
version.tags_json
|
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
|
||||||
.reject { _1.fetch('category') == 'nico' }
|
|
||||||
.map { Post.tag_snapshot_literal(_1) }
|
|
||||||
.sort
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_snapshot_from_record post
|
def post_snapshot_from_record post
|
||||||
@@ -670,6 +714,7 @@ class PostsController < ApplicationController
|
|||||||
def editable_tag_names_from_post post
|
def editable_tag_names_from_post post
|
||||||
post
|
post
|
||||||
.post_tags
|
.post_tags
|
||||||
|
.kept
|
||||||
.joins(tag: :tag_name)
|
.joins(tag: :tag_name)
|
||||||
.merge(Tag.not_nico)
|
.merge(Tag.not_nico)
|
||||||
.merge(Tag.where(deprecated_at: nil))
|
.merge(Tag.where(deprecated_at: nil))
|
||||||
|
|||||||
@@ -200,45 +200,14 @@ class TagsController < ApplicationController
|
|||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
rows = normalise_deerjikist_rows(tag)
|
|
||||||
return if performed?
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tag.lock!
|
tag.deerjikists = []
|
||||||
|
params[:_json].each.with_index do |item, i|
|
||||||
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
platform = item[:platform]
|
||||||
row_indexes_by_key = rows_by_key(rows)
|
code = normalise_deerjikist_code(platform, item[:code])
|
||||||
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
|
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
|
||||||
current_deerjikists = locked_deerjikists.filter { |deerjikist|
|
deerjikist.tag = tag
|
||||||
deerjikist.tag_id == tag.id
|
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
|
||||||
}
|
|
||||||
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
|
|
||||||
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
|
|
||||||
}
|
|
||||||
|
|
||||||
render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag)
|
|
||||||
raise ActiveRecord::Rollback if performed?
|
|
||||||
|
|
||||||
requested_keys_set = requested_keys.to_set
|
|
||||||
current_deerjikists.each do |deerjikist|
|
|
||||||
key = [deerjikist.platform, deerjikist.code]
|
|
||||||
deerjikist.destroy! unless requested_keys_set.include?(key)
|
|
||||||
end
|
|
||||||
|
|
||||||
existing_keys = requested_deerjikists.to_h { |deerjikist|
|
|
||||||
[[deerjikist.platform, deerjikist.code], true]
|
|
||||||
}
|
|
||||||
requested_keys.each do |platform, code|
|
|
||||||
next if existing_keys[[platform, code]]
|
|
||||||
|
|
||||||
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
|
||||||
row_index = row_indexes_by_key[[platform, code]].first
|
|
||||||
begin
|
|
||||||
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
|
|
||||||
rescue ActiveRecord::RecordNotUnique
|
|
||||||
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
|
|
||||||
render_deerjikist_conflicts(conflicts, row_indexes_by_key, tag)
|
|
||||||
end
|
|
||||||
raise ActiveRecord::Rollback if performed?
|
raise ActiveRecord::Rollback if performed?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -684,7 +653,6 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def normalise_deerjikist_code platform, code
|
def normalise_deerjikist_code platform, code
|
||||||
code = code.to_s
|
|
||||||
return code if platform != 'youtube' || code[0] != '@'
|
return code if platform != 'youtube' || code[0] != '@'
|
||||||
|
|
||||||
url = "https://www.youtube.com/#{ code }"
|
url = "https://www.youtube.com/#{ code }"
|
||||||
@@ -701,82 +669,6 @@ class TagsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_deerjikist_rows tag
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
params[:_json].each.with_index do |item, index|
|
|
||||||
platform = item[:platform]
|
|
||||||
unless Deerjikist.platforms.key?(platform)
|
|
||||||
render_deerjikist_platform_invalid(index)
|
|
||||||
return rows
|
|
||||||
end
|
|
||||||
|
|
||||||
code = normalise_deerjikist_code(platform, item[:code])
|
|
||||||
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
|
||||||
unless deerjikist.valid?
|
|
||||||
render_deerjikist_form_record_invalid(deerjikist, index)
|
|
||||||
return rows
|
|
||||||
end
|
|
||||||
|
|
||||||
rows << { index:, platform:, code: }
|
|
||||||
end
|
|
||||||
|
|
||||||
rows
|
|
||||||
end
|
|
||||||
|
|
||||||
def lock_deerjikists_for_tag_update tag_id, keys
|
|
||||||
clauses = ['tag_id = ?']
|
|
||||||
values = [tag_id]
|
|
||||||
|
|
||||||
keys.each do |platform, code|
|
|
||||||
clauses << '(platform = ? AND code = ?)'
|
|
||||||
values << platform << code
|
|
||||||
end
|
|
||||||
|
|
||||||
Deerjikist
|
|
||||||
.where(clauses.join(' OR '), *values)
|
|
||||||
.order(:platform, :code)
|
|
||||||
.lock
|
|
||||||
.to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag
|
|
||||||
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
|
|
||||||
return if conflicts.empty?
|
|
||||||
|
|
||||||
tag_names_by_id = Tag
|
|
||||||
.joins(:tag_name)
|
|
||||||
.where(id: conflicts.map(&:tag_id).uniq)
|
|
||||||
.pluck('tags.id', 'tag_names.name')
|
|
||||||
.to_h
|
|
||||||
fields = { }
|
|
||||||
|
|
||||||
conflicts.each do |deerjikist|
|
|
||||||
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
|
|
||||||
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
|
|
||||||
field = :"deerjikists.#{ index }.code"
|
|
||||||
fields[field] ||= []
|
|
||||||
fields[field] << message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
render_validation_error fields:
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_platform_invalid index
|
|
||||||
render_validation_error fields: {
|
|
||||||
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def rows_by_key rows
|
|
||||||
rows.each_with_object({ }) do |row, result|
|
|
||||||
key = [row[:platform], row[:code]]
|
|
||||||
result[key] ||= []
|
|
||||||
result[key] << row[:index]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_form_record_invalid deerjikist, index
|
def render_deerjikist_form_record_invalid deerjikist, index
|
||||||
fields = { }
|
fields = { }
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ class Post < ApplicationRecord
|
|||||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
||||||
has_many :tags, through: :post_tags
|
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 :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
||||||
through: :post_tags,
|
through: :active_post_tags, source: :tag
|
||||||
source: :tag
|
|
||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
@@ -122,6 +123,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
def snapshot_tag_names
|
def snapshot_tag_names
|
||||||
post_tags
|
post_tags
|
||||||
|
.kept
|
||||||
.joins(tag: :tag_name)
|
.joins(tag: :tag_name)
|
||||||
.includes(:sections, tag: :tag_name)
|
.includes(:sections, tag: :tag_name)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
@@ -135,38 +137,8 @@ class Post < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.tag_snapshot_literal tag
|
|
||||||
sections = tag.fetch('sections', []).map do |sec|
|
|
||||||
begin_ms = sec.fetch('begin_ms')
|
|
||||||
end_ms = sec['end_ms']
|
|
||||||
|
|
||||||
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
|
|
||||||
end
|
|
||||||
|
|
||||||
"#{ tag.fetch('name') }#{ sections.join }"
|
|
||||||
end
|
|
||||||
|
|
||||||
def snapshot_tags_json
|
|
||||||
post_tags
|
|
||||||
.joins(tag: :tag_name)
|
|
||||||
.includes(:sections, tag: :tag_name)
|
|
||||||
.order('tags.id')
|
|
||||||
.map do |pt|
|
|
||||||
{ '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 }
|
|
||||||
} }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.section_literal section
|
def self.section_literal section
|
||||||
end_ms =
|
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
|
||||||
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
|
|
||||||
|
|
||||||
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ms_to_time ms
|
def self.ms_to_time ms
|
||||||
@@ -351,9 +323,7 @@ class Post < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.external_svg_reference?(attribute_name, attribute_value)
|
def self.external_svg_reference?(attribute_name, attribute_value)
|
||||||
if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
return external_svg_url?(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 style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
|
||||||
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
||||||
|
|
||||||
@@ -374,12 +344,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
def self.svg_url_function_disallowed?(value)
|
def self.svg_url_function_disallowed?(value)
|
||||||
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
||||||
reference =
|
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
|
||||||
entry.to_s.strip
|
|
||||||
.delete_prefix("'")
|
|
||||||
.delete_prefix('"')
|
|
||||||
.delete_suffix("'")
|
|
||||||
.delete_suffix('"')
|
|
||||||
reference.present? && !(reference.start_with?('#'))
|
reference.present? && !(reference.start_with?('#'))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -468,12 +433,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
match =
|
match =
|
||||||
value.match(
|
value.match(
|
||||||
/
|
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
||||||
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
|
|
||||||
(?::(\d{2})(?:\.(\d+))?)?
|
|
||||||
(Z|[+-]\d{2}:?\d{2})?
|
|
||||||
\z
|
|
||||||
/x)
|
|
||||||
return nil if match.nil?
|
return nil if match.nil?
|
||||||
|
|
||||||
year = match[1].to_i
|
year = match[1].to_i
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
class PostTag < ApplicationRecord
|
class PostTag < ApplicationRecord
|
||||||
|
include Discard::Model
|
||||||
|
|
||||||
|
before_destroy do
|
||||||
|
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
|
||||||
|
end
|
||||||
|
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :tag, counter_cache: :post_count
|
belongs_to :tag, counter_cache: :post_count
|
||||||
belongs_to :created_user, class_name: 'User', optional: true
|
belongs_to :created_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :deleted_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
|
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
|
||||||
foreign_key: [:post_id, :tag_id],
|
foreign_key: [:post_id, :tag_id],
|
||||||
@@ -11,5 +18,18 @@ class PostTag < ApplicationRecord
|
|||||||
|
|
||||||
validates :post_id, presence: true
|
validates :post_id, presence: true
|
||||||
validates :tag_id, presence: true
|
validates :tag_id, presence: true
|
||||||
validates :post_id, uniqueness: { scope: :tag_id }
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class PostTagSection < ApplicationRecord
|
|||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :tag
|
belongs_to :tag
|
||||||
|
|
||||||
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
|
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
|
||||||
primary_key: [:post_id, :tag_id],
|
primary_key: [:post_id, :tag_id],
|
||||||
inverse_of: :sections,
|
inverse_of: :sections,
|
||||||
optional: true
|
optional: true
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
has_many :post_tags, inverse_of: :tag
|
has_many :post_tags, inverse_of: :tag
|
||||||
has_many :posts, through: :post_tags
|
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 :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
|
||||||
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
||||||
@@ -257,11 +259,11 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
||||||
|
|
||||||
source_tag.post_tags.find_each do |source_pt|
|
source_tag.post_tags.kept.find_each do |source_pt|
|
||||||
post_id = source_pt.post_id
|
post_id = source_pt.post_id
|
||||||
affected_post_ids << post_id
|
affected_post_ids << post_id
|
||||||
source_pt.destroy!
|
source_pt.discard_by!(created_by_user)
|
||||||
unless PostTag.exists?(post_id:, tag: target_tag)
|
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
||||||
PostTag.create!(post_id:, tag: target_tag)
|
PostTag.create!(post_id:, tag: target_tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -291,7 +293,7 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
# 投稿件数を再集計
|
# 投稿件数を再集計
|
||||||
target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
|
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
|
||||||
end
|
end
|
||||||
|
|
||||||
target_tag.reload
|
target_tag.reload
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ module PostRepr
|
|||||||
|
|
||||||
def tag_json post
|
def tag_json post
|
||||||
post
|
post
|
||||||
.post_tags
|
.active_post_tags
|
||||||
.reject { _1.tag.deprecated? }
|
.reject { _1.tag.deprecated? }
|
||||||
.sort_by { _1.tag.name }
|
.sort_by { _1.tag.name }
|
||||||
.map do |post_tag|
|
.map { |post_tag|
|
||||||
TagRepr.inline(post_tag.tag).merge(
|
TagRepr.inline(post_tag.tag).merge(
|
||||||
'children' => [],
|
'children' => [],
|
||||||
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
||||||
end
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_url post, host: nil
|
def thumbnail_url post, host: nil
|
||||||
@@ -106,14 +106,11 @@ module PostRepr
|
|||||||
|
|
||||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
|
||||||
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
||||||
payload = {
|
Rails.logger.warn(
|
||||||
post_id: post.id,
|
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
|
||||||
attachment_id: post.thumbnail.attachment&.id,
|
"attachment_id=#{post.thumbnail.attachment&.id} " \
|
||||||
blob_id: post.thumbnail.blob&.id,
|
"blob_id=#{post.thumbnail.blob&.id} " \
|
||||||
error_class: e.class,
|
"error_class=#{e.class} message=#{e.message}")
|
||||||
message: e.message }
|
|
||||||
|
|
||||||
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
|
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -85,9 +85,7 @@ class PostCreatePlan
|
|||||||
end_raw: match[3],
|
end_raw: match[3],
|
||||||
tag_name: name)
|
tag_name: name)
|
||||||
end
|
end
|
||||||
if name.include?('[') || name.include?(']')
|
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
|
||||||
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
[resolved_tag_name(name), category&.to_sym, sections]
|
[resolved_tag_name(name), category&.to_sym, sections]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -121,11 +121,9 @@ class PostCreator
|
|||||||
def sync_post_tags! post, desired_tags, sections
|
def sync_post_tags! post, desired_tags, sections
|
||||||
desired_ids = desired_tags.map(&:id).to_set
|
desired_ids = desired_tags.map(&:id).to_set
|
||||||
current_ids = post.tags.pluck(:id).to_set
|
current_ids = post.tags.pluck(:id).to_set
|
||||||
|
|
||||||
Tag.where(id: desired_ids - current_ids).find_each do |tag|
|
Tag.where(id: desired_ids - current_ids).find_each do |tag|
|
||||||
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTagSection.where(post_id: post.id).destroy_all
|
PostTagSection.where(post_id: post.id).destroy_all
|
||||||
sections.each do |tag_id, ranges|
|
sections.each do |tag_id, ranges|
|
||||||
ranges.each do |begin_ms, end_ms|
|
ranges.each do |begin_ms, end_ms|
|
||||||
@@ -135,9 +133,10 @@ class PostCreator
|
|||||||
end_ms:)
|
end_ms:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id,
|
PostTag.where(post_id: post.id,
|
||||||
tag_id: (current_ids - desired_ids).to_a).destroy_all
|
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
|
||||||
|
post_tag.discard_by!(@actor)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_parent_posts! post, ids
|
def sync_parent_posts! post, ids
|
||||||
|
|||||||
@@ -225,15 +225,15 @@ class PostImportPreviewer
|
|||||||
end
|
end
|
||||||
{ data:, warnings:, validation_errors: { } }
|
{ data:, warnings:, validation_errors: { } }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
payload = { error: e.class.name, message: e.message }
|
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
"post_import_metadata_fetch_unsafe_url "\
|
||||||
|
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||||
rescue Preview::HttpFetcher::FetchFailed,
|
rescue Preview::HttpFetcher::FetchFailed,
|
||||||
Preview::HttpFetcher::ResponseTooLarge => e
|
Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
payload = { error: e.class.name, message: e.message }
|
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
"post_import_metadata_fetch_failure "\
|
||||||
|
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -335,14 +335,14 @@ class PostImportPreviewer
|
|||||||
def safe_fetch_metadata url
|
def safe_fetch_metadata url
|
||||||
fetch_metadata(url)
|
fetch_metadata(url)
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
payload = { error: e.class.name, message: e.message }
|
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
"post_import_metadata_fetch_unsafe_url "\
|
||||||
|
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
payload = { error: e.class.name, message: e.message }
|
|
||||||
Rails.logger.error(
|
Rails.logger.error(
|
||||||
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
|
"post_import_metadata_fetch_unexpected_failure "\
|
||||||
|
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ require 'date'
|
|||||||
|
|
||||||
class PostMetadataFetcher
|
class PostMetadataFetcher
|
||||||
TIMESTAMP_PATTERN =
|
TIMESTAMP_PATTERN =
|
||||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
|
Regexp.new(
|
||||||
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
|
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
|
||||||
(Z|[+-]\d{2}:?\d{2})?\z/x
|
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
|
||||||
|
'(Z|[+-]\d{2}:?\d{2})?\z')
|
||||||
|
|
||||||
def self.fetch raw_url
|
def self.fetch raw_url
|
||||||
uri, = Preview::UrlSafety.validate(raw_url)
|
uri, = Preview::UrlSafety.validate(raw_url)
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class PostVersionRecorder < VersionRecorder
|
|||||||
thumbnail_base: @record.thumbnail_base,
|
thumbnail_base: @record.thumbnail_base,
|
||||||
video_ms: @record.video_ms,
|
video_ms: @record.video_ms,
|
||||||
tags: @record.snapshot_tag_names.join(' '),
|
tags: @record.snapshot_tag_names.join(' '),
|
||||||
tags_json: @record.snapshot_tags_json,
|
|
||||||
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: @record.original_created_from,
|
original_created_from: @record.original_created_from,
|
||||||
original_created_before: @record.original_created_before }
|
original_created_before: @record.original_created_before }
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
|
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
|
||||||
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
|
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
|
||||||
desired_tag_ids = desired_tag_ids.compact.to_set
|
desired_tag_ids = desired_tag_ids.compact.to_set
|
||||||
|
|
||||||
to_add = desired_tag_ids - current_tag_ids
|
to_add = desired_tag_ids - current_tag_ids
|
||||||
@@ -117,8 +117,8 @@ module Youtube
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
||||||
pt.destroy!
|
pt.discard_by!(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require 'active_support/core_ext/integer/time'
|
require "active_support/core_ext/integer/time"
|
||||||
|
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
# Settings specified here will take precedence over those in config/application.rb.
|
# Settings specified here will take precedence over those in config/application.rb.
|
||||||
@@ -17,8 +17,8 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
|
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
|
||||||
# Run rails dev:cache to toggle Action Controller caching.
|
# Run rails dev:cache to toggle Action Controller caching.
|
||||||
if Rails.root.join('tmp/caching-dev.txt').exist?
|
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||||
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
|
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
|
||||||
else
|
else
|
||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
|||||||
get :metadata
|
get :metadata
|
||||||
post :bulk
|
post :bulk
|
||||||
get :random
|
get :random
|
||||||
|
get :changes
|
||||||
get :versions, to: 'post_versions#index'
|
get :versions, to: 'post_versions#index'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
生成ファイル
+29
-18
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -311,12 +311,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "post_id", null: false
|
t.bigint "post_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
t.bigint "created_user_id"
|
t.bigint "created_user_id"
|
||||||
|
t.bigint "deleted_user_id"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "discarded_at"
|
||||||
|
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
|
||||||
|
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
|
||||||
|
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
|
||||||
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
|
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
|
||||||
|
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
|
||||||
|
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
|
||||||
|
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
|
||||||
|
t.index ["post_id"], name: "index_post_tags_on_post_id"
|
||||||
|
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
|
||||||
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -338,7 +349,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
t.string "url", limit: 768, null: false
|
t.string "url", limit: 768, null: false
|
||||||
t.string "thumbnail_base", limit: 2000
|
t.string "thumbnail_base", limit: 2000
|
||||||
t.text "tags", null: false
|
t.text "tags", null: false
|
||||||
t.json "tags_json", null: false
|
|
||||||
t.text "parent_post_ids", null: false
|
t.text "parent_post_ids", null: false
|
||||||
t.datetime "original_created_from"
|
t.datetime "original_created_from"
|
||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
@@ -352,7 +362,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
|
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
|
||||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
||||||
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
||||||
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"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}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
@@ -384,6 +393,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
|
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "wiki_page_id", null: false
|
||||||
|
t.integer "no", null: false
|
||||||
|
t.string "alt_text"
|
||||||
|
t.binary "sha256", limit: 32, null: false
|
||||||
|
t.bigint "created_by_user_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
|
||||||
|
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
|
||||||
|
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
t.bigint "parent_tag_id", null: false
|
t.bigint "parent_tag_id", null: false
|
||||||
@@ -592,19 +614,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
t.index ["banned_at"], name: "index_users_on_banned_at"
|
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.bigint "wiki_page_id", null: false
|
|
||||||
t.integer "no", null: false
|
|
||||||
t.string "alt_text"
|
|
||||||
t.binary "sha256", limit: 32, null: false
|
|
||||||
t.bigint "created_by_user_id", null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
|
|
||||||
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
|
|
||||||
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "sha256", limit: 64, null: false
|
t.string "sha256", limit: 64, null: false
|
||||||
t.text "body", null: false
|
t.text "body", null: false
|
||||||
@@ -713,10 +722,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) do
|
|||||||
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
||||||
add_foreign_key "post_similarities", "posts"
|
add_foreign_key "post_similarities", "posts"
|
||||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||||
add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
|
add_foreign_key "post_tag_sections", "posts"
|
||||||
|
add_foreign_key "post_tag_sections", "tags"
|
||||||
add_foreign_key "post_tags", "posts"
|
add_foreign_key "post_tags", "posts"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
|
add_foreign_key "post_tags", "users", column: "deleted_user_id"
|
||||||
add_foreign_key "post_versions", "posts"
|
add_foreign_key "post_versions", "posts"
|
||||||
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace :nico do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
|
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
|
||||||
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
|
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
|
||||||
desired_tag_ids = desired_tag_ids.compact.to_set
|
desired_tag_ids = desired_tag_ids.compact.to_set
|
||||||
|
|
||||||
to_add = desired_tag_ids - current_tag_ids
|
to_add = desired_tag_ids - current_tag_ids
|
||||||
@@ -30,8 +30,8 @@ namespace :nico do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
||||||
pt.destroy!
|
pt.discard_by!(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,5 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe PostTag, type: :model do
|
RSpec.describe PostTag, type: :model do
|
||||||
describe 'uniqueness' do
|
|
||||||
it 'rejects duplicate post and tag pairs but allows either to be reused' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
|
|
||||||
|
|
||||||
expect(duplicate).not_to be_valid
|
|
||||||
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
|
|
||||||
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
|
|
||||||
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'enforces uniqueness in the database when validation is bypassed' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
|
|
||||||
|
|
||||||
expect { duplicate.save!(validate: false) }
|
|
||||||
.to raise_error(ActiveRecord::RecordNotUnique)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#destroy!' do
|
|
||||||
it 'deletes only the selected pair and its sections and updates the counter' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
same_post = create(:post_tag, post: post_tag.post)
|
|
||||||
same_tag = create(:post_tag, tag: post_tag.tag)
|
|
||||||
sections = [post_tag, same_post, same_tag].map do |link|
|
|
||||||
create(:post_tag_section, post: link.post, tag: link.tag,
|
|
||||||
begin_ms: 1000, end_ms: 2000)
|
|
||||||
end
|
|
||||||
|
|
||||||
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
|
|
||||||
.and change(PostTagSection, :count).by(-1)
|
|
||||||
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
|
|
||||||
|
|
||||||
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
|
|
||||||
expect(same_post.reload).to be_persisted
|
|
||||||
expect(same_tag.reload).to be_persisted
|
|
||||||
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
|
|
||||||
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
|
|
||||||
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows a removed tag to be added again without restoring old sections' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
|
|
||||||
begin_ms: 1000, end_ms: 2000)
|
|
||||||
post_tag.destroy!
|
|
||||||
|
|
||||||
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
|
|
||||||
|
|
||||||
expect(replacement.reload.sections).to be_empty
|
|
||||||
expect(replacement.tag.reload.post_count).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#sections' do
|
describe '#sections' do
|
||||||
it 'loads the owning post_tag from a section using both keys' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
create(:post_tag, post: post_tag.post)
|
|
||||||
create(:post_tag, tag: post_tag.tag)
|
|
||||||
section = create(:post_tag_section, post: post_tag.post,
|
|
||||||
tag: post_tag.tag,
|
|
||||||
begin_ms: 1000, end_ms: 2000)
|
|
||||||
|
|
||||||
expect(section.reload.post_tag).to eq(post_tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'loads sections by post_id and tag_id' do
|
it 'loads sections by post_id and tag_id' do
|
||||||
post_tag = create(:post_tag)
|
post_tag = create(:post_tag)
|
||||||
section = create(:post_tag_section,
|
section = create(:post_tag_section,
|
||||||
@@ -80,25 +12,18 @@ RSpec.describe PostTag, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not load sections for another tag on the same post' do
|
it 'does not load sections for another tag on the same post' do
|
||||||
post_tag = create(:post_tag)
|
post = create(:post)
|
||||||
post = post_tag.post
|
tag = create(:tag)
|
||||||
other_tag = create(:tag)
|
other_tag = create(:tag)
|
||||||
|
|
||||||
own_section = create(:post_tag_section,
|
post_tag = create(:post_tag, post:, tag:)
|
||||||
post:,
|
|
||||||
tag: post_tag.tag,
|
|
||||||
begin_ms: 1000,
|
|
||||||
end_ms: 2000)
|
|
||||||
|
|
||||||
create(:post_tag, post:, tag: other_tag)
|
|
||||||
|
|
||||||
create(:post_tag_section,
|
create(:post_tag_section,
|
||||||
post:,
|
post:,
|
||||||
tag: other_tag,
|
tag: other_tag,
|
||||||
begin_ms: 1000,
|
begin_ms: 1000,
|
||||||
end_ms: 2000)
|
end_ms: 2000)
|
||||||
|
|
||||||
expect(post_tag.reload.sections).to contain_exactly(own_section)
|
expect(post_tag.sections).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows open-ended sections' do
|
it 'allows open-ended sections' do
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ RSpec.describe PostVersion, type: :model do
|
|||||||
url: post_record.url,
|
url: post_record.url,
|
||||||
thumbnail_base: post_record.thumbnail_base,
|
thumbnail_base: post_record.thumbnail_base,
|
||||||
tags: post_record.snapshot_tag_names.join(' '),
|
tags: post_record.snapshot_tag_names.join(' '),
|
||||||
tags_json: post_record.snapshot_tags_json,
|
|
||||||
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post_record.original_created_from,
|
original_created_from: post_record.original_created_from,
|
||||||
original_created_before: post_record.original_created_before,
|
original_created_before: post_record.original_created_before,
|
||||||
|
|||||||
@@ -185,14 +185,15 @@ RSpec.describe Tag, type: :model do
|
|||||||
context 'when merging a simple source tag' do
|
context 'when merging a simple source tag' do
|
||||||
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
||||||
|
|
||||||
it 'deletes the source link, links the target, and aliases the discarded source tag' do
|
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
|
||||||
described_class.merge_tags!(target_tag, [source_tag])
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
|
||||||
target_link = PostTag.find_by(post: post_record, 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)
|
||||||
|
|
||||||
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
|
expect(source_pt.discarded_at).to be_present
|
||||||
expect(target_link).to be_present
|
expect(source_pt.tag_id).to eq(source_tag.id)
|
||||||
expect(source_tag.reload.post_count).to eq(0)
|
expect(active_target).to be_present
|
||||||
|
|
||||||
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
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(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
|
||||||
@@ -205,22 +206,16 @@ RSpec.describe Tag, type: :model do
|
|||||||
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
|
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
|
||||||
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
||||||
|
|
||||||
it 'deletes the source link and preserves the existing target link' do
|
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
|
||||||
create(:post_tag_section, post: post_record, tag: source_tag,
|
|
||||||
begin_ms: 1000, end_ms: 2000)
|
|
||||||
target_section = create(:post_tag_section, post: post_record,
|
|
||||||
tag: target_tag,
|
|
||||||
begin_ms: 3000, end_ms: nil)
|
|
||||||
|
|
||||||
described_class.merge_tags!(target_tag, [source_tag])
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
|
||||||
target_links = PostTag.where(post: post_record, tag: target_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)
|
||||||
|
|
||||||
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
|
expect(source_pt.discarded_at).to be_present
|
||||||
expect(target_links).to contain_exactly(target_post_tag)
|
expect(source_pt.tag_id).to eq(source_tag.id)
|
||||||
expect(source_tag.reload.post_count).to eq(0)
|
expect(active.count).to eq(1)
|
||||||
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
|
expect(active.first.id).to eq(target_post_tag.id)
|
||||||
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
|
|
||||||
|
|
||||||
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
||||||
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
|
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
|
||||||
@@ -235,12 +230,14 @@ RSpec.describe Tag, type: :model do
|
|||||||
it 'ignores the target in source_tags while still merging the source tag' do
|
it 'ignores the target in source_tags while still merging the source tag' do
|
||||||
described_class.merge_tags!(target_tag, [source_tag, target_tag])
|
described_class.merge_tags!(target_tag, [source_tag, target_tag])
|
||||||
|
|
||||||
target_link = PostTag.find_by(post: post_record, 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)
|
||||||
|
|
||||||
expect(Tag.find(target_tag.id)).to be_present
|
expect(Tag.find(target_tag.id)).to be_present
|
||||||
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
||||||
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
|
expect(source_pt.discarded_at).to be_present
|
||||||
expect(target_link).to be_present
|
expect(source_pt.tag_id).to eq(source_tag.id)
|
||||||
|
expect(active_target).to be_present
|
||||||
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
|
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
|
||||||
expect(target_tag.reload.post_count).to eq(1)
|
expect(target_tag.reload.post_count).to eq(1)
|
||||||
end
|
end
|
||||||
@@ -266,10 +263,13 @@ RSpec.describe Tag, type: :model do
|
|||||||
it 'still merges, but discards the source tag_name instead of aliasing it' do
|
it 'still merges, but discards the source tag_name instead of aliasing it' do
|
||||||
described_class.merge_tags!(target_tag, [source_tag])
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
|
||||||
target_link = PostTag.find_by(post: post_record, 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)
|
||||||
|
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
|
||||||
|
|
||||||
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
|
expect(source_pt.discarded_at).to be_present
|
||||||
expect(target_link).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(Tag.with_discarded.find(source_tag.id)).to be_discarded
|
||||||
expect(target_tag.reload.post_count).to eq(1)
|
expect(target_tag.reload.post_count).to eq(1)
|
||||||
@@ -289,20 +289,14 @@ RSpec.describe Tag, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'rolls back the transaction' do
|
it 'rolls back the transaction' do
|
||||||
source_section = create(:post_tag_section, post: post_record,
|
|
||||||
tag: source_tag,
|
|
||||||
begin_ms: 1000, end_ms: 2000)
|
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
described_class.merge_tags!(target_tag, [source_tag])
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
|
|
||||||
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
|
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
|
||||||
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
|
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
|
||||||
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
|
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
|
||||||
expect(source_post_tag.sections).to contain_exactly(source_section)
|
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
|
||||||
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(source_tag_name.reload.canonical_id).to be_nil
|
||||||
expect(target_tag.reload.post_count).to eq(0)
|
expect(target_tag.reload.post_count).to eq(0)
|
||||||
end
|
end
|
||||||
@@ -339,7 +333,6 @@ RSpec.describe Tag, type: :model do
|
|||||||
url: post.url,
|
url: post.url,
|
||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
tags: snapshot_tags(post),
|
tags: snapshot_tags(post),
|
||||||
tags_json: post.snapshot_tags_json,
|
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
@@ -371,15 +364,12 @@ RSpec.describe Tag, type: :model do
|
|||||||
expect(latest.event_type).to eq('update')
|
expect(latest.event_type).to eq('update')
|
||||||
expect(latest.created_by_user).to be_nil
|
expect(latest.created_by_user).to be_nil
|
||||||
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
|
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
|
||||||
expect(latest.tags_json.map { |item| item.fetch('id') }).to eq([target_tag.id])
|
|
||||||
expect(affected_versions.first.tags_json.map { |item| item.fetch('id') })
|
|
||||||
.to eq([source_tag.id])
|
|
||||||
|
|
||||||
expect(unaffected_post.reload.post_versions.count).to eq(1)
|
expect(unaffected_post.reload.post_versions.count).to eq(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the source tag has no post_tags' do
|
context 'when the source tag has no active post_tags' do
|
||||||
let!(:another_post) do
|
let!(:another_post) do
|
||||||
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
|
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
|
||||||
end
|
end
|
||||||
|
|||||||
+130
-160
@@ -55,7 +55,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
video_ms: post.video_ms,
|
video_ms: post.video_ms,
|
||||||
tags: post.snapshot_tag_names.join(' '),
|
tags: post.snapshot_tag_names.join(' '),
|
||||||
tags_json: post.snapshot_tags_json,
|
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
@@ -102,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /posts' do
|
describe "GET /posts" do
|
||||||
let!(:user) { create_member_user! }
|
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) { Tag.create!(tag_name:, category: :general) }
|
||||||
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
||||||
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
||||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||||
|
|
||||||
let!(:hit_post) do
|
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|
|
url: 'https://example.com/spec2').tap do |p|
|
||||||
PostTag.create!(post: p, tag:)
|
PostTag.create!(post: p, tag:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:miss_post) do
|
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|
|
url: 'https://example.com/spec3').tap do |p|
|
||||||
PostTag.create!(post: p, tag: tag2)
|
PostTag.create!(post: p, tag: tag2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns posts with tag name in JSON' do
|
it "returns posts with tag name in JSON" do
|
||||||
get '/posts'
|
get "/posts"
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
posts = json.fetch('posts')
|
posts = json.fetch("posts")
|
||||||
|
|
||||||
# 全postの全tagが name を含むこと
|
# 全postの全tagが name を含むこと
|
||||||
expect(posts).not_to be_empty
|
expect(posts).not_to be_empty
|
||||||
@@ -142,21 +141,18 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json['count']).to be_an(Integer)
|
expect(json['count']).to be_an(Integer)
|
||||||
|
|
||||||
# spec_tag を含む投稿が存在すること
|
# spec_tag を含む投稿が存在すること
|
||||||
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
|
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
|
||||||
expect(all_tag_names).to include('spec_tag')
|
expect(all_tag_names).to include("spec_tag")
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps children and sections keys in non-detail tag responses' do
|
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)
|
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'
|
get '/posts'
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
|
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' }
|
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
|
||||||
|
|
||||||
expect(tag_json.fetch('children')).to eq([])
|
expect(tag_json.fetch('children')).to eq([])
|
||||||
@@ -165,29 +161,9 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preloads tag details and sections as the number of posts grows' do
|
context "when q is provided" do
|
||||||
5.times do
|
it "filters posts by q (hit case)" do
|
||||||
link = create(:post_tag, post: create(:post, uploaded_user: user))
|
get "/posts", params: { tags: "spec_tag" }
|
||||||
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)
|
expect(response).to have_http_status(:ok)
|
||||||
posts = json.fetch('posts')
|
posts = json.fetch('posts')
|
||||||
@@ -205,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'filters posts by q (hit case by alias)' do
|
it "filters posts by q (hit case by alias)" do
|
||||||
get '/posts', params: { tags: 'manko' }
|
get "/posts", params: { tags: "manko" }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
posts = json.fetch('posts')
|
posts = json.fetch('posts')
|
||||||
@@ -224,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns empty posts when nothing matches' do
|
it "returns empty posts when nothing matches" do
|
||||||
get '/posts', params: { tags: 'no_such_keyword_12345' }
|
get "/posts", params: { tags: "no_such_keyword_12345" }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
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)
|
expect(json.fetch('count')).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -483,71 +459,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
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
|
context 'when original_created_from/original_created_to are provided' do
|
||||||
# 注意: controller の現状ロジックに合わせてる
|
# 注意: controller の現状ロジックに合わせてる
|
||||||
# original_created_from は `original_created_before > ?`
|
# original_created_from は `original_created_before > ?`
|
||||||
@@ -1250,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when nico tag already exists in tags' do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||||
@@ -1507,11 +1418,9 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
it '200 and updates title + resync tags when member' do
|
it '200 and updates title + resync tags when member' do
|
||||||
sign_in_as(member)
|
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')
|
tn2 = TagName.create!(name: 'spec_tag_2')
|
||||||
replacement_tag = Tag.create!(tag_name: tn2, category: :general)
|
Tag.create!(tag_name: tn2, category: :general)
|
||||||
|
|
||||||
put "/posts/#{post_record.id}", params: post_update_params(
|
put "/posts/#{post_record.id}", params: post_update_params(
|
||||||
post_record,
|
post_record,
|
||||||
@@ -1524,38 +1433,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
names = json['tags'].map { |n| n['name'] }
|
names = json['tags'].map { |n| n['name'] }
|
||||||
expect(names).to include('spec_tag_2')
|
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('id' => tag.id,
|
|
||||||
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
|
|
||||||
expect(versions.last.tags_json.map { |item| item.fetch('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('id') }
|
|
||||||
end
|
|
||||||
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects a deprecated tag specified directly' do
|
it 'rejects a deprecated tag specified directly' do
|
||||||
@@ -1578,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when nico tag already exists in tags' do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||||
@@ -1835,7 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(post_record.reload.title).to eq('updated by other user')
|
expect(post_record.reload.title).to eq('updated by other user')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
|
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
base_version = create_post_version_for!(post_record.reload)
|
base_version = create_post_version_for!(post_record.reload)
|
||||||
@@ -2001,29 +1878,123 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns viewed state and current tags with their sections' do
|
it '200 and returns viewed boolean' 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'
|
get '/posts/random'
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to have_key('viewed')
|
expect(json).to have_key('viewed')
|
||||||
expect([true, false]).to include(json['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
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /posts/changes' do
|
describe 'GET /posts/changes' do
|
||||||
it 'returns 404 for the retired history endpoint' do
|
let(:member) { create(:user, :member) }
|
||||||
get '/posts/changes'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
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)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2053,7 +2024,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
url: post.url,
|
url: post.url,
|
||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
tags: snapshot_tags(post),
|
tags: snapshot_tags(post),
|
||||||
tags_json: post.snapshot_tags_json,
|
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
@@ -2075,7 +2045,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
let!(:v2) do
|
let!(:v2) do
|
||||||
post_record.post_tags.find_by!(tag: tag).destroy!
|
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
|
||||||
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
||||||
post_record.update!(
|
post_record.update!(
|
||||||
title: 'updated spec post',
|
title: 'updated spec post',
|
||||||
|
|||||||
@@ -226,14 +226,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
|
|||||||
[platform2, code2],
|
[platform2, code2],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'locks the tag before replacing the complete list' do
|
|
||||||
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
|
|
||||||
|
|
||||||
do_request
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when tag already has deerjikists' do
|
context 'when tag already has deerjikists' do
|
||||||
@@ -307,81 +299,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when platform is outside the enum' do
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: 'invalid', code: code1 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 with an indexed platform error without changing the list' do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deerjikists.0.platform' => [be_present],
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a requested deerjikist belongs to another tag' do
|
|
||||||
let!(:other_tag) { create(:tag, category: :deerjikist) }
|
|
||||||
let!(:owned_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
end
|
|
||||||
let!(:conflicting_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
|
|
||||||
end
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: 'nico', code: 'new-code' },
|
|
||||||
{ platform: platform2, code: code2 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
other_tag.tag_name.update!(name: 'existing-deerjikist')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an indexed 422 error and rolls back the complete replacement' do
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deerjikists.1.code' => [include('existing-deerjikist')],
|
|
||||||
)
|
|
||||||
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
|
|
||||||
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a requested deerjikist already belongs to the same tag' do
|
|
||||||
let!(:existing_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
end
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: platform1, code: code1 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'keeps the existing row' do
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { existing_deerjikist.reload.created_at }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
|
|
||||||
.to eq([[platform1, code1]])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when youtube code is handle' do
|
context 'when youtube code is handle' do
|
||||||
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
|
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
|
||||||
let(:payload) do
|
let(:payload) do
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
@@ -268,10 +268,6 @@ RSpec.describe Youtube::Sync do
|
|||||||
expect(tag_ids).to include(deerjikist_tag.id)
|
expect(tag_ids).to include(deerjikist_tag.id)
|
||||||
expect(tag_ids).not_to include(Tag.no_deerjikist.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(
|
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
|
||||||
post,
|
post,
|
||||||
created_by_user: nil
|
created_by_user: nil
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
require 'rails_helper'
|
require "rails_helper"
|
||||||
|
|
||||||
|
|
||||||
RSpec.describe 'nico:sync' do
|
RSpec.describe "nico:sync" do
|
||||||
def stub_python(json_array)
|
def stub_python(json_array)
|
||||||
status = instance_double(Process::Status, success?: true)
|
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
|
end
|
||||||
|
|
||||||
def create_tag!(name, category:)
|
def create_tag!(name, category:)
|
||||||
@@ -16,21 +16,21 @@ RSpec.describe 'nico:sync' do
|
|||||||
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
|
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
|
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
|
||||||
# 既存 post(正規表現で拾われるURL)
|
# 既存 post(正規表現で拾われるURL)
|
||||||
post = Post.create!(
|
post = Post.create!(
|
||||||
title: 'old',
|
title: "old",
|
||||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
url: "https://www.nicovideo.jp/watch/sm9",
|
||||||
uploaded_user: nil
|
uploaded_user: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
# 既存の非nicoタグ(kept_non_nico_ids)
|
# 既存の非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)
|
PostTag.create!(post: post, tag: kept_general)
|
||||||
|
|
||||||
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
|
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
|
||||||
linked = create_tag!('spec_linked', category: 'general')
|
linked = create_tag!("spec_linked", category: "general")
|
||||||
nico = create_tag!('nico:AAA', category: 'nico')
|
nico = create_tag!("nico:AAA", category: "nico")
|
||||||
link_nico_to_tag!(nico, linked)
|
link_nico_to_tag!(nico, linked)
|
||||||
|
|
||||||
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
|
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
|
||||||
@@ -46,22 +46,22 @@ RSpec.describe 'nico:sync' do
|
|||||||
'deleted_at' => '2026-01-31 00:00:00' }])
|
'deleted_at' => '2026-01-31 00:00:00' }])
|
||||||
|
|
||||||
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
|
# 外部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
|
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("spec_kept")
|
||||||
expect(active_tag_names).to include('nico:AAA')
|
expect(active_tag_names).to include("nico:AAA")
|
||||||
expect(active_tag_names).to include('spec_linked')
|
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_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
|
||||||
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
|
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
|
||||||
|
|
||||||
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
|
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
|
||||||
expect(active_tag_names).to include('bot操作')
|
expect(active_tag_names).to include("bot操作")
|
||||||
end
|
end
|
||||||
|
|
||||||
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
||||||
@@ -108,47 +108,39 @@ RSpec.describe 'nico:sync' do
|
|||||||
expect(calls).to eq(2)
|
expect(calls).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '古い nico tag の関連を物理削除し、変更前後の履歴を version に残す' do
|
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||||
post = Post.create!(
|
post = Post.create!(
|
||||||
title: 'old',
|
title: "old",
|
||||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
url: "https://www.nicovideo.jp/watch/sm9",
|
||||||
uploaded_user: nil
|
uploaded_user: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||||
old_nico = create_tag!('nico:OLD', category: 'nico')
|
old_nico = create_tag!("nico:OLD", category: "nico")
|
||||||
PostTag.create!(post:, tag: old_nico)
|
old_pt = PostTag.create!(post: post, tag: old_nico)
|
||||||
create_post_version_for!(post)
|
expect(old_pt.discarded_at).to be_nil
|
||||||
|
|
||||||
# 今回は NEW のみ欲しい
|
# 今回は NEW のみ欲しい
|
||||||
new_nico = create_tag!('nico:NEW', category: 'nico')
|
new_nico = create_tag!("nico:NEW", category: "nico")
|
||||||
|
|
||||||
# bot/tagme 念のため
|
# bot/tagme 念のため
|
||||||
Tag.bot
|
Tag.bot
|
||||||
Tag.tagme
|
Tag.tagme
|
||||||
|
|
||||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
|
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
|
||||||
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")
|
||||||
|
|
||||||
expect(PostTag.exists?(post:, tag: old_nico)).to be(false)
|
# OLD は active から外れる(discarded_at が入る)
|
||||||
expect(old_nico.reload.post_count).to eq(0)
|
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
|
||||||
expect(new_nico.reload.post_count).to eq(1)
|
expect(old_pts.last.discarded_at).to be_present
|
||||||
|
|
||||||
versions = post.post_versions.order(:version_no)
|
|
||||||
expect(versions.first.tags_json.map { |item| item.fetch('id') })
|
|
||||||
.to include(old_nico.id)
|
|
||||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
|
||||||
.to include(new_nico.id)
|
|
||||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
|
||||||
.not_to include(old_nico.id)
|
|
||||||
|
|
||||||
# NEW は active にいる
|
# NEW は active にいる
|
||||||
post.reload
|
post.reload
|
||||||
active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
|
active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
|
||||||
expect(active_names).to include('nico:NEW')
|
expect(active_names).to include("nico:NEW")
|
||||||
expect(active_names).not_to include('nico:OLD')
|
expect(active_names).not_to include("nico:OLD")
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_tags(post)
|
def snapshot_tags(post)
|
||||||
@@ -164,7 +156,6 @@ RSpec.describe 'nico:sync' do
|
|||||||
url: post.url,
|
url: post.url,
|
||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
tags: snapshot_tags(post),
|
tags: snapshot_tags(post),
|
||||||
tags_json: post.snapshot_tags_json,
|
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
|
|||||||
@@ -92,9 +92,7 @@ describe ('PostEditForm', () => {
|
|||||||
.toHaveValue ('180.5')
|
.toHaveValue ('180.5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it (
|
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||||
'shows deduplicated original-created endpoint errors on the shared datetime field',
|
|
||||||
async () => {
|
|
||||||
const post = buildPost ()
|
const post = buildPost ()
|
||||||
api.isApiError.mockReturnValue (true)
|
api.isApiError.mockReturnValue (true)
|
||||||
postsApi.updatePost.mockRejectedValueOnce ({
|
postsApi.updatePost.mockRejectedValueOnce ({
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
const spanClass = 'tag-link-colour'
|
const spanClass = 'tag-link-colour'
|
||||||
const linkClass = 'tag-link-colour tag-link-hover-colour'
|
const linkClass = 'tag-link-colour tag-link-hover-colour'
|
||||||
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
|
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 markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||||
const countClass = 'shrink-0 self-end md:self-auto'
|
const countClass = 'shrink-0 self-end md:self-auto'
|
||||||
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
||||||
@@ -82,10 +84,7 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
?? (matchedAlias == null ? tag.name : `${ matchedAlias } → ${ tag.name }`)
|
?? (matchedAlias == null ? tag.name : `${ matchedAlias } → ${ tag.name }`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={rootClass}>
|
||||||
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)) && (
|
{(linkFlg && withWiki && isFullTag (tag)) && (
|
||||||
<span className={markerWrapClass}>
|
<span className={markerWrapClass}>
|
||||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import * as React from "react"
|
||||||
import { X } from 'lucide-react'
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import * as React from 'react'
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
@@ -17,23 +17,20 @@ const DialogClose = DialogPrimitive.Close
|
|||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
>(
|
>(({ className, ...props }, ref) => (
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/80',
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
className)}
|
className)}
|
||||||
{...props}/>))
|
{...props}
|
||||||
|
/>))
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(
|
>(({ className, children, ...props }, ref) => (
|
||||||
({ className, children, ...props }, ref) => (
|
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -48,7 +45,8 @@ const DialogContent = React.forwardRef<
|
|||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
className)}
|
className)}
|
||||||
{...props}>
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
@@ -65,48 +63,52 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogPortal>))
|
</DialogPortal>))
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = (
|
const DialogHeader = ({
|
||||||
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
|
className,
|
||||||
) => (
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col space-y-1.5 text-center md:text-left',
|
"flex flex-col space-y-1.5 text-center md:text-left",
|
||||||
className)}
|
className)}
|
||||||
{...props}/>)
|
{...props}
|
||||||
DialogHeader.displayName = 'DialogHeader'
|
/>)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
const DialogFooter = (
|
const DialogFooter = ({
|
||||||
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
|
className,
|
||||||
) => (
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2',
|
"flex flex-col-reverse md:flex-row md:justify-end md:space-x-2",
|
||||||
className)}
|
className)}
|
||||||
{...props}/>)
|
{...props}
|
||||||
DialogFooter.displayName = 'DialogFooter'
|
/>)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(
|
>(({ className, ...props }, ref) => (
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-lg font-semibold leading-none tracking-tight',
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className)}
|
className)}
|
||||||
{...props}/>))
|
{...props}
|
||||||
|
/>))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(
|
>(({ className, ...props }, ref) => (
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn ('text-sm text-muted-foreground', className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}/>))
|
{...props}
|
||||||
|
/>))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ describe ('post new review URL state', () => {
|
|||||||
.toEqual (['one', 'two'])
|
.toEqual (['one', 'two'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('allows at most a 6 143 byte request target', () => {
|
it ('allows at most a 4095-byte request target', () => {
|
||||||
const baseUrl = 'https://example.com/'
|
const baseUrl = 'https://example.com/'
|
||||||
const baseLength = postNewReviewPathByteLength ([baseUrl])
|
const baseLength = postNewReviewPathByteLength ([baseUrl])
|
||||||
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }`
|
const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
|
||||||
const denied = `${ allowed }a`
|
const denied = `${ allowed }a`
|
||||||
|
|
||||||
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143)
|
expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
|
||||||
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
|
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
|
||||||
expect (postNewReviewPathByteLength ([denied])).toBe (6_144)
|
expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
|
||||||
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
|
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
||||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
|
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
|
||||||
|
|
||||||
const textEncoder = new TextEncoder ()
|
const textEncoder = new TextEncoder ()
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
|
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
|
||||||
import { materialsKeys } from '@/lib/queryKeys'
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
import { cn, dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
@@ -113,11 +113,10 @@ const clearedTagSelectionPath = (
|
|||||||
|
|
||||||
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
|
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
|
||||||
<div
|
<div
|
||||||
className={cn (
|
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
|
||||||
'flex aspect-square h-[180px] w-[180px] items-center justify-center',
|
overflow-hidden rounded-lg border border-stone-200 bg-white text-center
|
||||||
'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
|
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
|
||||||
'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
|
dark:text-stone-100`}>
|
||||||
'dark:text-stone-100')}>
|
|
||||||
{material.thumbnail
|
{material.thumbnail
|
||||||
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
|
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
|
||||||
: (
|
: (
|
||||||
@@ -535,29 +534,27 @@ const MaterialListPage: FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateQuery ({ view: 'card' })}
|
onClick={() => updateQuery ({ view: 'card' })}
|
||||||
className={cn (
|
className={`rounded-full border px-4 py-2 text-sm ${
|
||||||
'rounded-full border px-4 py-2 text-sm',
|
|
||||||
view === 'card'
|
view === 'card'
|
||||||
? [
|
? [
|
||||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||||
'dark:bg-sky-950 dark:text-sky-100']
|
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||||
: [
|
: [
|
||||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||||
アイコン
|
アイコン
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateQuery ({ view: 'list' })}
|
onClick={() => updateQuery ({ view: 'list' })}
|
||||||
className={cn (
|
className={`rounded-full border px-4 py-2 text-sm ${
|
||||||
'rounded-full border px-4 py-2 text-sm',
|
|
||||||
view === 'list'
|
view === 'list'
|
||||||
? [
|
? [
|
||||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||||
'dark:bg-sky-950 dark:text-sky-100']
|
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||||
: [
|
: [
|
||||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||||
詳細
|
詳細
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe ('PostImportSourcePage', () => {
|
|||||||
const input = screen.getByRole ('textbox', { name: '' })
|
const input = screen.getByRole ('textbox', { name: '' })
|
||||||
|
|
||||||
fireEvent.change (input, {
|
fireEvent.change (input, {
|
||||||
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
|
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
|
||||||
|
|
||||||
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -262,9 +262,7 @@ describe ('TheatreDetailPage', () => {
|
|||||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it (
|
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
||||||
'shows child tags from the post tag tree in both vertical and horizontal layouts',
|
|
||||||
async () => {
|
|
||||||
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
||||||
const parentTag = buildTag ({
|
const parentTag = buildTag ({
|
||||||
id: 11,
|
id: 11,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it } from 'vitest'
|
||||||
|
|
||||||
|
describe ('pending high-level browser coverage', () => {
|
||||||
|
it.todo ('adds MSW-backed API boundary tests in a follow-up issue')
|
||||||
|
it.todo ('covers TheatreDetailPage with timer polling, comment posting, and next-post updates')
|
||||||
|
it.todo ('covers NicoTagListPage linking and pagination against realistic API payloads')
|
||||||
|
it.todo ('covers TagDetailSidebar drag/drop parent-child editing with pointer-event fidelity')
|
||||||
|
it.todo ('covers TopNav desktop and mobile menu flows as browser-level integration tests')
|
||||||
|
it.todo ('covers full App bootstrap for user creation, user verification, and 503 handling')
|
||||||
|
})
|
||||||
新しいイシューから参照
ユーザーをブロックする