コミットを比較
17 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| c2102c8f96 | |||
| 510cbb0d78 | |||
| a820ce4c3e | |||
| 507ce1680e | |||
| ec2b3d2254 | |||
| ffd28c0f9e | |||
| a54ca72244 | |||
| 5bbd6eda11 | |||
| ece95838f0 | |||
| 7ab46f907f | |||
| e94720941c | |||
| def6870f06 | |||
| c361c561c2 | |||
| 979ccf598e | |||
| 37ade2a988 | |||
| 7d48a8f694 | |||
| 3980e9651e |
@@ -107,11 +107,16 @@ npm run preview
|
|||||||
- Prefer single quotes for strings unless interpolation or escaping makes
|
- Prefer single quotes for strings unless interpolation or escaping makes
|
||||||
double quotes better.
|
double quotes better.
|
||||||
- Ruby: never put a space before method-call parentheses.
|
- Ruby: never put a space before method-call parentheses.
|
||||||
|
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
||||||
- Ruby: never put a line break immediately before `)`.
|
- Ruby: never put a line break immediately before `)`.
|
||||||
- Ruby: do not use `%w` or `%i`.
|
- Ruby: do not use `%w` or `%i`.
|
||||||
|
- In Ruby, when an `if` condition is split across multiple lines and combines
|
||||||
|
clauses with `&&` or `||`, wrap the whole condition in parentheses.
|
||||||
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
||||||
- Ruby hashes keep the first pair on the same line as `{` unless line length
|
- Ruby hashes keep the first pair on the same line as `{` unless line length
|
||||||
requires a break.
|
requires a break.
|
||||||
|
- Short Ruby hashes may stay visually compact across two lines with the first
|
||||||
|
pair kept on the opening line and aligned continuation pairs below it.
|
||||||
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
|
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
|
||||||
indentation.
|
indentation.
|
||||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||||
@@ -125,13 +130,85 @@ npm run preview
|
|||||||
- TypeScript and TSX use 4-space logical indentation.
|
- TypeScript and TSX use 4-space logical indentation.
|
||||||
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
|
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
|
||||||
- Tabs are only for leading indentation, never for spaces after non-space text.
|
- Tabs are only for leading indentation, never for spaces after non-space text.
|
||||||
|
- TypeScript and TSX imports may stay on one line if they remain within the
|
||||||
|
line limit; do not expand short type-only imports mechanically.
|
||||||
|
- In TypeScript and TSX, when a function takes one destructured object
|
||||||
|
argument plus an inline type, prefer this shape when it fits locally:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const helper = (
|
||||||
|
{ value, flag }: { value: string
|
||||||
|
flag: boolean },
|
||||||
|
): Result => {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- In TypeScript and TSX, put `switch` case block braces on their own lines
|
||||||
|
when a case needs a lexical block:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'yes':
|
||||||
|
case 'no':
|
||||||
|
{
|
||||||
|
const expected = valueFor (item)
|
||||||
|
return expected == null || expected === answer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- In TypeScript and TSX, use `value == null` and `value != null` as the
|
||||||
|
default nullish checks. Do not use `=== null`, `=== undefined`,
|
||||||
|
`!== null`, or `!== undefined`.
|
||||||
|
- If code appears to need a distinction between `null` and `undefined`, treat
|
||||||
|
that as a design smell and revise the logic to avoid the distinction.
|
||||||
|
External library APIs that explicitly require distinguishing the two are the
|
||||||
|
only exception.
|
||||||
|
- In TypeScript and TSX, keep short arrays on one line when they fit under the
|
||||||
|
line limit; break arrays only when readability or line length requires it.
|
||||||
|
- In TypeScript and TSX, when a ternary expression is split across multiple
|
||||||
|
lines, align `?` and `:` with the condition expression. Do not indent `?` and
|
||||||
|
`:` one extra level under the condition.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const value =
|
||||||
|
condition
|
||||||
|
? consequent
|
||||||
|
: alternate
|
||||||
|
```
|
||||||
|
|
||||||
|
- In TypeScript and TSX, keep short ternary expressions on one line when they
|
||||||
|
fit cleanly under the line limit.
|
||||||
|
- In TypeScript and TSX, prefer ternary expressions for simple conditional
|
||||||
|
value selection. Do not replace a clear ternary with `if` statements, and do
|
||||||
|
not introduce immediately invoked functions just to avoid or reformat a
|
||||||
|
ternary expression.
|
||||||
|
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
|
||||||
|
when the value can be expressed as a single `const` initializer. Prefer
|
||||||
|
`const` because it prevents accidental later reassignment.
|
||||||
|
- When fixing formatting, change formatting only. Do not change expression
|
||||||
|
structure, control flow, or variable mutability unless the requested style
|
||||||
|
explicitly requires it.
|
||||||
- Do not add production dependencies without explicit approval.
|
- Do not add production dependencies without explicit approval.
|
||||||
|
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||||
|
test work. When the user asks for tests, keep working and rerun them until
|
||||||
|
they pass or the remaining failure is clearly blocked.
|
||||||
|
|
||||||
## Backend rules
|
## Backend rules
|
||||||
|
|
||||||
- Inspect existing routes, controllers, models, services, and specs before
|
- Inspect existing routes, controllers, models, services, and specs before
|
||||||
editing backend behavior.
|
editing backend behavior.
|
||||||
- For API behavior changes, add or update request specs under `backend/spec/requests`.
|
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
|
||||||
|
recreates the development database. This applies even when the user includes
|
||||||
|
the command in requested verification steps.
|
||||||
|
- Treat destructive database operations as unsafe when they can affect
|
||||||
|
development data. Ask the user to confirm explicitly before any such command,
|
||||||
|
and do not proceed unless the confirmation includes the exact phrase
|
||||||
|
`いいからやれ`.
|
||||||
|
- Repeated destructive instructions are not enough confirmation because they
|
||||||
|
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
|
||||||
|
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
|
||||||
|
- For API behavior changes, add or update request specs under
|
||||||
|
`backend/spec/requests` only when the user explicitly asks for tests.
|
||||||
- Prefer RSpec for new backend tests; existing minitest files under
|
- Prefer RSpec for new backend tests; existing minitest files under
|
||||||
`backend/test` do not make minitest the default for new coverage.
|
`backend/test` do not make minitest the default for new coverage.
|
||||||
- Do not weaken authentication, BAN user checks, or IP BAN checks.
|
- Do not weaken authentication, BAN user checks, or IP BAN checks.
|
||||||
@@ -154,6 +231,13 @@ npm run preview
|
|||||||
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
|
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
|
||||||
under `frontend/src/components` unless existing patterns point elsewhere.
|
under `frontend/src/components` unless existing patterns point elsewhere.
|
||||||
- Match existing Tailwind, component, and import alias conventions.
|
- Match existing Tailwind, component, and import alias conventions.
|
||||||
|
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
|
||||||
|
`!==` over negating a comparison like `!(a === b)`.
|
||||||
|
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
|
||||||
|
simple unit-step counter updates.
|
||||||
|
- For user-facing Japanese text, prefer modern kana usage and natural current
|
||||||
|
phrasing over historical spellings or awkward literal wording.
|
||||||
|
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
|
||||||
|
|
||||||
### Frontend TSX style
|
### Frontend TSX style
|
||||||
|
|
||||||
@@ -175,6 +259,9 @@ npm run preview
|
|||||||
single physical line.
|
single physical line.
|
||||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||||
two or more physical lines, even if it is one statement.
|
two or more physical lines, even if it is one statement.
|
||||||
|
- Do not use a leading semicolon for expression statements such as
|
||||||
|
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
|
||||||
|
explicitly, for example with `void`.
|
||||||
|
|
||||||
Preferred:
|
Preferred:
|
||||||
|
|
||||||
@@ -211,10 +298,11 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
|||||||
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
||||||
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
||||||
behavior, inspect the related request specs and service objects.
|
behavior, inspect the related request specs and service objects.
|
||||||
- If frontend code changes, run the existing frontend verification commands
|
- If frontend code changes, run only non-test verification commands that
|
||||||
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
|
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
|
||||||
- If backend code changes, run the relevant RSpec command; for broad backend
|
only when the user explicitly asks for tests.
|
||||||
changes, run `bundle exec rspec`.
|
- If backend code changes, do not run RSpec unless the user explicitly asks
|
||||||
|
for tests.
|
||||||
- If a verification command cannot be run or fails, report the exact command and failure.
|
- If a verification command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
## Completion criteria
|
## Completion criteria
|
||||||
@@ -222,7 +310,8 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
|||||||
A task is complete only when:
|
A task is complete only when:
|
||||||
|
|
||||||
- implementation is complete,
|
- implementation is complete,
|
||||||
- relevant verification commands pass, or failures are clearly explained,
|
- relevant non-test verification commands pass, or failures are clearly
|
||||||
|
explained,
|
||||||
- unrelated files are not changed,
|
- unrelated files are not changed,
|
||||||
- migrations and schema are consistent when schema changes are made,
|
- migrations and schema are consistent when schema changes are made,
|
||||||
- user-facing behavior is documented when needed.
|
- user-facing behavior is documented when needed.
|
||||||
|
|||||||
+17
-4
@@ -47,6 +47,10 @@ bundle exec rspec
|
|||||||
|
|
||||||
If a command cannot be run or fails, report the exact command and failure.
|
If a command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
|
Do not create, modify, or run tests unless the user explicitly asks for test
|
||||||
|
work. When the user asks for tests, keep working and rerun them until they
|
||||||
|
pass or the remaining failure is clearly blocked.
|
||||||
|
|
||||||
## Rails structure
|
## Rails structure
|
||||||
|
|
||||||
- `app/controllers`: API controllers.
|
- `app/controllers`: API controllers.
|
||||||
@@ -68,17 +72,23 @@ service, representation, and spec.
|
|||||||
- Prefer precise, minimal changes.
|
- Prefer precise, minimal changes.
|
||||||
- Use single quotes unless interpolation or escaping makes double quotes better.
|
- Use single quotes unless interpolation or escaping makes double quotes better.
|
||||||
- Do not put a space before Ruby method-call parentheses.
|
- Do not put a space before Ruby method-call parentheses.
|
||||||
|
- For `render`-family method calls, omit parentheses even when passing
|
||||||
|
keyword arguments.
|
||||||
- Never put a line break immediately before `)` in Ruby.
|
- Never put a line break immediately before `)` in Ruby.
|
||||||
- Do not use `%w` or `%i` in new Ruby code.
|
- Do not use `%w` or `%i` in new Ruby code.
|
||||||
- Never write a Ruby line longer than 99 characters.
|
- Never write a Ruby line longer than 99 characters.
|
||||||
- Aim to keep Ruby lines within 79 characters where practical.
|
- Aim to keep Ruby lines within 79 characters where practical.
|
||||||
- For small Ruby method definitions that take keyword arguments, match the
|
- For small Ruby method definitions that take keyword arguments, match the
|
||||||
local no-parentheses style when nearby code uses it.
|
local no-parentheses style when nearby code uses it.
|
||||||
|
- When an `if` condition is split across multiple lines and combines clauses
|
||||||
|
with `&&` or `||`, wrap the whole condition in parentheses.
|
||||||
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
|
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
|
||||||
rules.
|
rules.
|
||||||
- Do not format Ruby hashes like Ruby blocks.
|
- Do not format Ruby hashes like Ruby blocks.
|
||||||
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
|
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
|
||||||
- Keep the first pair on the same line as `{` by default.
|
- Keep the first pair on the same line as `{` by default.
|
||||||
|
- Short Ruby hashes may stay visually compact across two lines with the first
|
||||||
|
pair kept on the opening line and aligned continuation pairs below it.
|
||||||
- If the hash would exceed the line limit, break after `{` and indent pairs
|
- If the hash would exceed the line limit, break after `{` and indent pairs
|
||||||
by 4 spaces.
|
by 4 spaces.
|
||||||
- Put one logical pair per line when the expression would otherwise become
|
- Put one logical pair per line when the expression would otherwise become
|
||||||
@@ -116,7 +126,8 @@ service, representation, and spec.
|
|||||||
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
||||||
- Do not weaken BAN or IP BAN behavior.
|
- Do not weaken BAN or IP BAN behavior.
|
||||||
- If changing request authentication or controller before actions, add or
|
- If changing request authentication or controller before actions, add or
|
||||||
update request specs covering banned users and banned IP addresses.
|
update request specs covering banned users and banned IP addresses only when
|
||||||
|
the user explicitly asks for tests.
|
||||||
|
|
||||||
## RSpec
|
## RSpec
|
||||||
|
|
||||||
@@ -130,8 +141,9 @@ service, representation, and spec.
|
|||||||
- `AuthHelper#sign_in_as(user)` stubs
|
- `AuthHelper#sign_in_as(user)` stubs
|
||||||
`ApplicationController#current_user`; use it when matching existing
|
`ApplicationController#current_user`; use it when matching existing
|
||||||
request spec style.
|
request spec style.
|
||||||
- Add or update request specs for API behavior changes, especially status
|
- Add or update request specs for API behavior changes only when the user
|
||||||
codes, permissions, response shape, and version conflict behavior.
|
explicitly asks for tests, especially status codes, permissions, response
|
||||||
|
shape, and version conflict behavior.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
@@ -164,7 +176,8 @@ service, representation, and spec.
|
|||||||
the record `version_no`.
|
the record `version_no`.
|
||||||
- Do not update versioned records without considering whether a version snapshot must be created.
|
- Do not update versioned records without considering whether a version snapshot must be created.
|
||||||
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
|
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
|
||||||
`merge` semantics and cover conflicts in request specs.
|
`merge` semantics. Cover conflicts in request specs only when the user
|
||||||
|
explicitly asks for tests.
|
||||||
|
|
||||||
## Domain cautions
|
## Domain cautions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
class GekanatorGamesController < ApplicationController
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
guessed_post_id = params.require(:guessed_post_id)
|
||||||
|
correct_post_id = params[:correct_post_id].presence
|
||||||
|
answers = params.require(:answers).as_json
|
||||||
|
|
||||||
|
game = GekanatorGame.new(
|
||||||
|
user: current_user,
|
||||||
|
guessed_post_id:,
|
||||||
|
correct_post_id:,
|
||||||
|
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
|
||||||
|
question_count: answers.length,
|
||||||
|
answers:)
|
||||||
|
|
||||||
|
if game.invalid?
|
||||||
|
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
learned_example_count = 0
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
game.save!
|
||||||
|
learned_example_count = learn_answers_from_game!(game)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
id: game.id,
|
||||||
|
learned_example_count:
|
||||||
|
}, status: :created
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def extra_questions
|
||||||
|
game = find_owned_game
|
||||||
|
return if performed?
|
||||||
|
|
||||||
|
questions =
|
||||||
|
GekanatorQuestion
|
||||||
|
.accepted
|
||||||
|
.includes(:gekanator_question_examples)
|
||||||
|
.where(kind: 'post_similarity', source: 'user_suggested')
|
||||||
|
.to_a
|
||||||
|
|
||||||
|
selected =
|
||||||
|
prioritized_extra_questions(
|
||||||
|
questions,
|
||||||
|
post_id: game.correct_post_id,
|
||||||
|
user: current_user,
|
||||||
|
limit: 6)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
questions: selected.map { |question| extra_question_json(question) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def extra_question_answers
|
||||||
|
game = find_owned_game
|
||||||
|
return if performed?
|
||||||
|
|
||||||
|
answer_params = params.require(:answers)
|
||||||
|
if !answer_params.is_a?(Array)
|
||||||
|
return render_validation_error fields: { answers: ['配列で指定してください.'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
answers = answer_params.map { |answer|
|
||||||
|
{
|
||||||
|
question_id: answer.require(:question_id).to_i,
|
||||||
|
answer: answer.require(:answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] })
|
||||||
|
question_by_id = questions.index_by(&:id)
|
||||||
|
if questions.length != answers.length
|
||||||
|
return render_validation_error fields: { answers: ['質問が見つかりません.'] }
|
||||||
|
end
|
||||||
|
if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' }
|
||||||
|
return render_validation_error fields: { answers: ['質問が不正です.'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
answers.each do |item|
|
||||||
|
question = question_by_id[item[:question_id]]
|
||||||
|
example =
|
||||||
|
GekanatorQuestionExample.find_or_initialize_by(
|
||||||
|
gekanator_question: question,
|
||||||
|
post: game.correct_post,
|
||||||
|
user: current_user)
|
||||||
|
example.record_answer!(
|
||||||
|
answer: item[:answer],
|
||||||
|
source: 'post_game_extra',
|
||||||
|
gekanator_game: game)
|
||||||
|
example.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { count: answers.length }, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extra_question_json question
|
||||||
|
{
|
||||||
|
id: question.id,
|
||||||
|
text: question.text,
|
||||||
|
source: question.source,
|
||||||
|
priority_weight: question.priority_weight
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def prioritized_extra_questions questions, post_id:, user:, limit:
|
||||||
|
answered_question_ids =
|
||||||
|
GekanatorQuestionExample
|
||||||
|
.where(user:, gekanator_question_id: questions.map(&:id))
|
||||||
|
.distinct
|
||||||
|
.pluck(:gekanator_question_id)
|
||||||
|
unanswered, answered =
|
||||||
|
questions.partition { |question| !answered_question_ids.include?(question.id) }
|
||||||
|
selected = weighted_sample_questions(unanswered, post_id:, limit:)
|
||||||
|
return selected if selected.length >= limit
|
||||||
|
|
||||||
|
selected + weighted_sample_questions(
|
||||||
|
answered.reject { |question| selected.any? { _1.id == question.id } },
|
||||||
|
post_id:,
|
||||||
|
limit: limit - selected.length)
|
||||||
|
end
|
||||||
|
|
||||||
|
def weighted_sample_questions questions, post_id:, limit:
|
||||||
|
remaining = questions.uniq(&:id)
|
||||||
|
selected = []
|
||||||
|
|
||||||
|
while selected.length < limit && remaining.any?
|
||||||
|
weighted =
|
||||||
|
remaining.map { |question|
|
||||||
|
[question, selection_weight_for(question, post_id: post_id)]
|
||||||
|
}
|
||||||
|
total_weight = weighted.sum { |_question, weight| weight }
|
||||||
|
break if total_weight <= 0
|
||||||
|
|
||||||
|
target = rand * total_weight
|
||||||
|
cumulative = 0.0
|
||||||
|
chosen =
|
||||||
|
weighted.find do |_question, weight|
|
||||||
|
cumulative += weight
|
||||||
|
cumulative >= target
|
||||||
|
end&.first || weighted.first.first
|
||||||
|
|
||||||
|
selected << chosen
|
||||||
|
remaining.reject! { |question| question.id == chosen.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
selected
|
||||||
|
end
|
||||||
|
|
||||||
|
def selection_weight_for question, post_id:
|
||||||
|
sample_count =
|
||||||
|
question.gekanator_question_examples.sum { |example|
|
||||||
|
next 0 unless example.post_id == post_id
|
||||||
|
|
||||||
|
example.sample_count.presence || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
question.priority_weight.to_f / (1.0 + sample_count * 0.15)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_owned_game
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
game = GekanatorGame.find_by(id: params[:id])
|
||||||
|
return head :not_found unless game
|
||||||
|
if !current_user.admin? && game.user_id != current_user.id
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
game
|
||||||
|
end
|
||||||
|
|
||||||
|
def learn_answers_from_game! game
|
||||||
|
correct_post = game.correct_post
|
||||||
|
return 0 if correct_post.blank?
|
||||||
|
|
||||||
|
accepted_questions =
|
||||||
|
GekanatorQuestion
|
||||||
|
.accepted
|
||||||
|
.index_by { |question| public_question_id_for(question) }
|
||||||
|
learned_count = 0
|
||||||
|
|
||||||
|
Array(game.answers).each do |answer|
|
||||||
|
answer_value = answer['answer'].to_s
|
||||||
|
next if answer_value.blank? || answer_value == 'unknown'
|
||||||
|
|
||||||
|
question_id = game_answer_question_id(answer)
|
||||||
|
next if question_id.blank?
|
||||||
|
|
||||||
|
question = accepted_questions[question_id.to_s]
|
||||||
|
next unless learnable_game_answer_question?(question)
|
||||||
|
|
||||||
|
example =
|
||||||
|
GekanatorQuestionExample.find_or_initialize_by(
|
||||||
|
gekanator_question: question,
|
||||||
|
post: correct_post,
|
||||||
|
user: current_user)
|
||||||
|
example.record_answer!(
|
||||||
|
answer: answer_value,
|
||||||
|
source: 'post_game_answer',
|
||||||
|
gekanator_game: game)
|
||||||
|
example.save!
|
||||||
|
learned_count += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
learned_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_question_id_for question
|
||||||
|
condition = normalize_condition(question.condition)
|
||||||
|
|
||||||
|
case condition[:type]
|
||||||
|
when 'tag'
|
||||||
|
"tag:#{condition[:key]}"
|
||||||
|
when 'source'
|
||||||
|
"source:#{condition[:host]}"
|
||||||
|
when 'original-year'
|
||||||
|
"original-year:#{condition[:year]}"
|
||||||
|
when 'original-month'
|
||||||
|
"original-month:#{condition[:month]}"
|
||||||
|
when 'original-month-day'
|
||||||
|
"original-month-day:#{condition[:monthDay] || condition[:month_day]}"
|
||||||
|
when 'title-length-at-least'
|
||||||
|
"title:length-at-least:#{condition[:length]}"
|
||||||
|
when 'title-length-greater-than'
|
||||||
|
"title:length-at-least:#{condition[:length].to_i + 1}"
|
||||||
|
when 'title-has-ascii'
|
||||||
|
'title:ascii'
|
||||||
|
when 'title-contains'
|
||||||
|
"title:contains:#{condition[:text]}"
|
||||||
|
when 'post-similarity'
|
||||||
|
"post-similarity:#{question.id}"
|
||||||
|
else
|
||||||
|
"catalog:#{question.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_condition condition
|
||||||
|
json = condition.deep_dup.as_json
|
||||||
|
|
||||||
|
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||||
|
json['monthDay'] = json.delete('month_day')
|
||||||
|
end
|
||||||
|
|
||||||
|
json.deep_symbolize_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def learnable_game_answer_question? question
|
||||||
|
return false if question.nil?
|
||||||
|
return true if question.kind == 'post_similarity'
|
||||||
|
return false unless question.kind == 'tag'
|
||||||
|
|
||||||
|
condition = normalize_condition(question.condition)
|
||||||
|
key = condition[:key].to_s
|
||||||
|
!key.start_with?('nico:')
|
||||||
|
end
|
||||||
|
|
||||||
|
def game_answer_question_id answer
|
||||||
|
answer['question_id'] ||
|
||||||
|
answer[:question_id] ||
|
||||||
|
answer['questionId'] ||
|
||||||
|
answer[:questionId]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
class GekanatorPostsController < ApplicationController
|
||||||
|
def index
|
||||||
|
posts =
|
||||||
|
Post
|
||||||
|
.preload(:post_similarities, tags: :tag_name)
|
||||||
|
.with_attached_thumbnail
|
||||||
|
.order(Arel.sql(
|
||||||
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||||
|
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
||||||
|
.to_a
|
||||||
|
|
||||||
|
active_tags_by_post_id =
|
||||||
|
posts.each_with_object({ }) do |post, h|
|
||||||
|
h[post.id] = post.tags.reject(&:deprecated?)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
posts: posts.map { |post|
|
||||||
|
post_json(post,
|
||||||
|
active_tags_by_post_id:)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def post_json post, active_tags_by_post_id:
|
||||||
|
{
|
||||||
|
id: post.id,
|
||||||
|
url: post.url,
|
||||||
|
title: post.title,
|
||||||
|
thumbnail: thumbnail_url(post),
|
||||||
|
thumbnail_base: post.thumbnail_base,
|
||||||
|
original_created_from: post.original_created_from,
|
||||||
|
original_created_before: post.original_created_before,
|
||||||
|
post_similarity_edges: post_similarity_edges_json(
|
||||||
|
post,
|
||||||
|
active_tags_by_post_id:),
|
||||||
|
tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_similarity_edges_json post, active_tags_by_post_id:
|
||||||
|
post
|
||||||
|
.post_similarities
|
||||||
|
.filter_map do |similarity|
|
||||||
|
next unless active_tags_by_post_id.key?(similarity.target_post_id)
|
||||||
|
|
||||||
|
{
|
||||||
|
target_post_id: similarity.target_post_id,
|
||||||
|
cos: similarity.cos.to_f
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_json tag
|
||||||
|
{
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_url post
|
||||||
|
return nil unless post.thumbnail.attached?
|
||||||
|
|
||||||
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
class GekanatorQuestionSuggestionsController < ApplicationController
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
|
||||||
|
return head :not_found unless game
|
||||||
|
if !current_user.admin? && game.user_id != current_user.id
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_question_id = params[:existing_question_id].presence
|
||||||
|
if existing_question_id
|
||||||
|
question = GekanatorQuestion.accepted.find_by(id: existing_question_id)
|
||||||
|
return head :not_found unless question
|
||||||
|
unless learnable_existing_question?(question)
|
||||||
|
return render_validation_error fields: { existing_question_id: ['質問が不正です.'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
example =
|
||||||
|
GekanatorQuestionExample.find_or_initialize_by(
|
||||||
|
gekanator_question: question,
|
||||||
|
post: game.correct_post,
|
||||||
|
user: current_user)
|
||||||
|
example.record_answer!(
|
||||||
|
answer: params.require(:answer),
|
||||||
|
source: 'post_game_extra',
|
||||||
|
gekanator_game: game)
|
||||||
|
|
||||||
|
if example.save
|
||||||
|
render json: {
|
||||||
|
id: question.id,
|
||||||
|
count: game.question_suggestions.count
|
||||||
|
}, status: :created
|
||||||
|
else
|
||||||
|
render_validation_error example
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
suggestion = GekanatorQuestionSuggestion.new(
|
||||||
|
gekanator_game: game,
|
||||||
|
user: current_user,
|
||||||
|
question_text: params.require(:question_text),
|
||||||
|
answer: params.require(:answer))
|
||||||
|
|
||||||
|
if suggestion.valid?
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
suggestion.save!
|
||||||
|
Gekanator::QuestionSuggestionPromoter.call(
|
||||||
|
suggestion: suggestion,
|
||||||
|
user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
id: suggestion.id,
|
||||||
|
count: game.question_suggestions.count
|
||||||
|
}, status: :created
|
||||||
|
else
|
||||||
|
render_validation_error suggestion
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ai_convert
|
||||||
|
return head :not_found unless current_user&.admin?
|
||||||
|
|
||||||
|
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
|
||||||
|
return head :not_found unless suggestion
|
||||||
|
if Gekanator::AiRunBudget.exceeded_after_next_run?
|
||||||
|
suggestion.gekanator_ai_runs.create!(
|
||||||
|
model: 'budget_guard',
|
||||||
|
status: 'blocked_budget',
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
estimated_cost_jpy: 0)
|
||||||
|
return head :payment_required
|
||||||
|
end
|
||||||
|
|
||||||
|
Gekanator::QuestionSuggestionAiConverter.call(
|
||||||
|
suggestion: suggestion,
|
||||||
|
user: current_user)
|
||||||
|
head :no_content
|
||||||
|
rescue NotImplementedError
|
||||||
|
head :not_implemented
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def learnable_existing_question? question
|
||||||
|
return true if question.kind == 'post_similarity'
|
||||||
|
return false unless question.kind == 'tag'
|
||||||
|
|
||||||
|
key = question.condition.as_json['key'].to_s
|
||||||
|
!key.start_with?('nico:')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
class GekanatorQuestionsController < ApplicationController
|
||||||
|
def index
|
||||||
|
questions =
|
||||||
|
GekanatorQuestion
|
||||||
|
.accepted
|
||||||
|
.includes(:gekanator_question_examples)
|
||||||
|
.order(priority_weight: :desc, id: :asc)
|
||||||
|
.to_a
|
||||||
|
deprecated_tag_keys = deprecated_tag_keys_for(questions)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
questions: questions.filter_map { |question|
|
||||||
|
json = question_json(question)
|
||||||
|
next if hidden_question?(json[:condition], deprecated_tag_keys)
|
||||||
|
|
||||||
|
json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def question_json question
|
||||||
|
condition = condition_json(question.condition).deep_symbolize_keys
|
||||||
|
json = {
|
||||||
|
record_id: question.id,
|
||||||
|
id: question_id_for(question, condition),
|
||||||
|
text: question_text_for(question, condition),
|
||||||
|
kind: question.kind,
|
||||||
|
condition: condition,
|
||||||
|
source: question.source,
|
||||||
|
priority_weight: question.priority_weight
|
||||||
|
}
|
||||||
|
if question.kind == 'post_similarity' || question.kind == 'tag'
|
||||||
|
json[:example_answers] = example_answers_json(question)
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
|
def question_id_for question, condition
|
||||||
|
case condition[:type]
|
||||||
|
when 'tag'
|
||||||
|
"tag:#{ condition[:key] }"
|
||||||
|
when 'source'
|
||||||
|
"source:#{ condition[:host] }"
|
||||||
|
when 'original-year'
|
||||||
|
"original-year:#{ condition[:year] }"
|
||||||
|
when 'original-month'
|
||||||
|
"original-month:#{ condition[:month] }"
|
||||||
|
when 'original-month-day'
|
||||||
|
"original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
|
||||||
|
when 'title-length-at-least'
|
||||||
|
"title:length-at-least:#{ condition[:length] }"
|
||||||
|
when 'title-length-greater-than'
|
||||||
|
"title:length-at-least:#{ condition[:length].to_i + 1 }"
|
||||||
|
when 'title-has-ascii'
|
||||||
|
'title:ascii'
|
||||||
|
when 'title-contains'
|
||||||
|
"title:contains:#{ condition[:text] }"
|
||||||
|
when 'post-similarity'
|
||||||
|
"post-similarity:#{ question.id }"
|
||||||
|
else
|
||||||
|
"catalog:#{ question.id }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def condition_json condition
|
||||||
|
json = condition.deep_dup.as_json
|
||||||
|
|
||||||
|
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||||
|
json['monthDay'] = json.delete('month_day')
|
||||||
|
end
|
||||||
|
|
||||||
|
if json['type'] == 'title-length-greater-than'
|
||||||
|
json['type'] = 'title-length-at-least'
|
||||||
|
json['length'] = json['length'].to_i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
|
def question_text_for question, condition
|
||||||
|
return question.text unless question.kind == 'title'
|
||||||
|
|
||||||
|
case condition[:type]
|
||||||
|
when 'title-length-at-least'
|
||||||
|
"タイトルは #{ condition[:length] } 文字以上?"
|
||||||
|
when 'title-contains'
|
||||||
|
"題名に「#{ condition[:text] }」が含まれる?"
|
||||||
|
else
|
||||||
|
question.text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def example_answers_json question
|
||||||
|
question
|
||||||
|
.gekanator_question_examples
|
||||||
|
.group_by(&:post_id)
|
||||||
|
.transform_values { |examples| aggregate_answer(examples) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def aggregate_answer examples
|
||||||
|
examples
|
||||||
|
.group_by(&:answer)
|
||||||
|
.map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
|
||||||
|
.sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
|
||||||
|
.first
|
||||||
|
&.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def deprecated_tag_keys_for questions
|
||||||
|
tag_keys = questions.filter_map { |question|
|
||||||
|
condition = condition_json(question.condition)
|
||||||
|
next unless condition['type'] == 'tag'
|
||||||
|
|
||||||
|
condition['key'].to_s.presence
|
||||||
|
}.uniq
|
||||||
|
return {} if tag_keys.empty?
|
||||||
|
|
||||||
|
categories = []
|
||||||
|
names = []
|
||||||
|
tag_keys.each do |key|
|
||||||
|
category, name = parse_tag_key(key)
|
||||||
|
categories << category
|
||||||
|
names << name
|
||||||
|
end
|
||||||
|
|
||||||
|
Tag
|
||||||
|
.joins(:tag_name)
|
||||||
|
.where(category: categories.uniq)
|
||||||
|
.where(tag_names: { name: names.uniq })
|
||||||
|
.where.not(deprecated_at: nil)
|
||||||
|
.pluck('tags.category', 'tag_names.name')
|
||||||
|
.each_with_object({ }) do |(category, name), h|
|
||||||
|
h["#{ category }:#{ name }"] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden_question? condition, deprecated_tag_keys
|
||||||
|
condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s]
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_tag_key key
|
||||||
|
parts = key.to_s.split(':')
|
||||||
|
[parts.first.to_s, parts.drop(1).join(':')]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
class MaterialsController < ApplicationController
|
class MaterialsController < ApplicationController
|
||||||
|
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
|
||||||
|
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
|
||||||
|
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
|
||||||
|
|
||||||
def index
|
def index
|
||||||
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
|
||||||
@@ -11,7 +15,7 @@ class MaterialsController < ApplicationController
|
|||||||
tag_id = params[:tag_id].presence
|
tag_id = params[:tag_id].presence
|
||||||
parent_id = params[:parent_id].presence
|
parent_id = params[:parent_id].presence
|
||||||
|
|
||||||
q = Material.includes(:tag, :created_by_user).with_attached_file
|
q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file
|
||||||
q = q.where(tag_id:) if tag_id
|
q = q.where(tag_id:) if tag_id
|
||||||
q = q.where(parent_id:) if parent_id
|
q = q.where(parent_id:) if parent_id
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ class MaterialsController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
material =
|
material =
|
||||||
Material
|
Material
|
||||||
.includes(:tag)
|
.includes(:tag, :material_export_items)
|
||||||
.with_attached_file
|
.with_attached_file
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
return head :not_found unless material
|
||||||
@@ -36,16 +40,25 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
tag_name_raw = params[:tag].to_s.strip
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
|
file_sha256 = MaterialFileSha256.from_upload(file)
|
||||||
url = params[:url].to_s.strip.presence
|
url = params[:url].to_s.strip.presence
|
||||||
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
||||||
if file.blank? && url.blank?
|
if file.blank? && url.blank?
|
||||||
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
||||||
url: ['ファイルまたは URL は必須です.'] }
|
url: ['ファイルまたは URL は必須です.'] }
|
||||||
end
|
end
|
||||||
|
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
|
||||||
|
return render_material_import_block(block) if block
|
||||||
|
|
||||||
|
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
|
||||||
|
material = nil
|
||||||
|
|
||||||
|
begin
|
||||||
|
Material.transaction do
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
tag = tag_name.tag
|
tag = tag_name.tag
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
@@ -53,9 +66,18 @@ class MaterialsController < ApplicationController
|
|||||||
material = Material.new(tag:, url:,
|
material = Material.new(tag:, url:,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
updated_by_user: current_user)
|
updated_by_user: current_user)
|
||||||
material.file.attach(file)
|
material.file.attach(uploaded_blob) if uploaded_blob
|
||||||
|
material.save!
|
||||||
|
upsert_export_paths!(material)
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: current_user)
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
uploaded_blob&.purge_later
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
if material.save
|
if material
|
||||||
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
||||||
else
|
else
|
||||||
render_validation_error material
|
render_validation_error material
|
||||||
@@ -71,29 +93,43 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
tag_name_raw = params[:tag].to_s.strip
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
url = params[:url].to_s.strip.presence
|
file_sha256 = MaterialFileSha256.from_upload(file)
|
||||||
|
url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
|
||||||
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
||||||
if file.blank? && url.blank?
|
if file.blank? && url.blank? && !material.file.attached?
|
||||||
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
||||||
url: ['ファイルまたは URL は必須です.'] }
|
url: ['ファイルまたは URL は必須です.'] }
|
||||||
end
|
end
|
||||||
|
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
|
||||||
|
return render_material_import_block(block) if block
|
||||||
|
|
||||||
|
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
|
||||||
|
|
||||||
|
begin
|
||||||
|
Material.transaction do
|
||||||
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
tag = tag_name.tag
|
tag = tag_name.tag
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
material.update!(tag:, url:, updated_by_user: current_user)
|
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||||
if file
|
if uploaded_blob
|
||||||
material.file.attach(file)
|
material.file.attach(uploaded_blob)
|
||||||
else
|
clear_file_suppression!(material)
|
||||||
material.file.purge
|
elsif params.key?(:url) && url.present? && file.blank?
|
||||||
|
material.file.detach
|
||||||
|
end
|
||||||
|
material.save!
|
||||||
|
upsert_export_paths!(material)
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :update,
|
||||||
|
created_by_user: current_user)
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
uploaded_blob&.purge_later
|
||||||
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
if material.save
|
|
||||||
render json: MaterialRepr.base(material, host: request.base_url)
|
render json: MaterialRepr.base(material, host: request.base_url)
|
||||||
else
|
|
||||||
render_validation_error material
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -103,7 +139,146 @@ class MaterialsController < ApplicationController
|
|||||||
material = Material.find_by(id: params[:id])
|
material = Material.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
return head :not_found unless material
|
||||||
|
|
||||||
material.discard
|
Material.transaction do
|
||||||
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
|
material.discard!
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :discard,
|
||||||
|
created_by_user: current_user)
|
||||||
|
end
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def download
|
||||||
|
zip = MaterialZipExporter.new(profile: params[:profile],
|
||||||
|
tag_id: params[:tag_id]).export
|
||||||
|
profile = params[:profile].presence || 'legacy_drive'
|
||||||
|
send_data zip,
|
||||||
|
type: 'application/zip',
|
||||||
|
disposition: 'attachment',
|
||||||
|
filename: "btrc-materials-#{ profile }.zip"
|
||||||
|
end
|
||||||
|
|
||||||
|
def suppress_file
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.admin?
|
||||||
|
|
||||||
|
material = Material.with_attached_file.find_by(id: params[:id])
|
||||||
|
return head :not_found unless material
|
||||||
|
|
||||||
|
reason = params[:reason].to_s.strip.presence
|
||||||
|
return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason
|
||||||
|
purge = bool?(:purge)
|
||||||
|
file_snapshot = purge_material_file_snapshot(material) if purge
|
||||||
|
attachment = purge && material.file.attached? ? material.file.attachment : nil
|
||||||
|
|
||||||
|
Material.transaction do
|
||||||
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
|
material.update!(file_suppressed_at: Time.current,
|
||||||
|
file_suppressed_by_user: current_user,
|
||||||
|
file_suppression_reason: reason,
|
||||||
|
updated_by_user: current_user)
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :suppress,
|
||||||
|
created_by_user: current_user,
|
||||||
|
file_snapshot:)
|
||||||
|
end
|
||||||
|
# Enqueue failure raises here after the suppress metadata has been committed.
|
||||||
|
# In that case the file remains suppressed in UI/ZIP and purge can be retried.
|
||||||
|
attachment&.purge_later
|
||||||
|
material.reload if purge
|
||||||
|
|
||||||
|
render json: MaterialRepr.base(material, host: request.base_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def upsert_export_paths! material
|
||||||
|
raw = params[:export_paths]
|
||||||
|
return if raw.blank?
|
||||||
|
|
||||||
|
export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
|
||||||
|
export_paths.each do |profile, export_path|
|
||||||
|
profile = profile.to_s
|
||||||
|
export_path = export_path.to_s.strip
|
||||||
|
item = material.material_export_items.find_or_initialize_by(profile:)
|
||||||
|
|
||||||
|
if export_path.blank?
|
||||||
|
item.destroy! if item.persisted?
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
item.export_path = export_path
|
||||||
|
item.enabled = true
|
||||||
|
item.created_by_user ||= current_user
|
||||||
|
item.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_zip_empty
|
||||||
|
render_unprocessable_entity('ZIP export 対象の素材がありません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_zip_duplicate_path error
|
||||||
|
render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }")
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_zip_missing_file error
|
||||||
|
missing_files = error.missing_files.map do |missing_file|
|
||||||
|
{ material_id: missing_file.material_id,
|
||||||
|
export_path: missing_file.export_path,
|
||||||
|
blob_id: missing_file.blob_id,
|
||||||
|
filename: missing_file.filename }
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { type: 'validation_error',
|
||||||
|
message: 'ZIP export に必要な素材ファイルが欠損しています.',
|
||||||
|
errors: { },
|
||||||
|
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
|
||||||
|
missing_files: },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_uploaded_material_blob! file, file_sha256
|
||||||
|
return nil unless file
|
||||||
|
|
||||||
|
file.tempfile.rewind
|
||||||
|
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: file.tempfile,
|
||||||
|
filename: file.original_filename,
|
||||||
|
content_type: file.content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_sha256.present?
|
||||||
|
blob.metadata['sha256'] = file_sha256
|
||||||
|
blob.save! if blob.changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
blob
|
||||||
|
ensure
|
||||||
|
file.tempfile.rewind if file&.tempfile
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_file_suppression! material
|
||||||
|
material.file_suppressed_at = nil
|
||||||
|
material.file_suppressed_by_user = nil
|
||||||
|
material.file_suppression_reason = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def purge_material_file_snapshot material
|
||||||
|
return nil unless material.file.attached?
|
||||||
|
|
||||||
|
blob = material.file.blob
|
||||||
|
|
||||||
|
{ file_blob_id: blob.id,
|
||||||
|
file_filename: blob.filename.to_s,
|
||||||
|
file_content_type: blob.content_type,
|
||||||
|
file_byte_size: blob.byte_size,
|
||||||
|
file_checksum: blob.checksum,
|
||||||
|
file_sha256: blob.metadata['sha256'] ||
|
||||||
|
MaterialFileSha256.from_blob(blob, allow_download: true) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_material_import_block block
|
||||||
|
render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ class PostsController < ApplicationController
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
|
|
||||||
tags = Tag.normalise_tags!(tag_names)
|
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
|
||||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
||||||
|
|
||||||
tags = Tag.expand_parent_tags(tags)
|
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||||
sync_post_tags!(post, tags)
|
sync_post_tags!(post, tags)
|
||||||
|
|
||||||
sync_parent_posts!(post, parent_post_ids)
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
@@ -165,6 +165,8 @@ class PostsController < ApplicationController
|
|||||||
render json: PostRepr.base(post), status: :created
|
render json: PostRepr.base(post), status: :created
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||||
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
|
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -255,6 +257,8 @@ class PostsController < ApplicationController
|
|||||||
render json:, status: :ok
|
render json:, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
||||||
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
|
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -378,7 +382,7 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_tree_for tags
|
def build_tag_tree_for tags
|
||||||
tags = tags.to_a
|
tags = tags.reject(&:deprecated?).to_a
|
||||||
tag_ids = tags.map(&:id)
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
||||||
@@ -501,7 +505,8 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def editable_tag_names_from_post post
|
def editable_tag_names_from_post post
|
||||||
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
post.tags.not_nico.where(deprecated_at: nil)
|
||||||
|
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
||||||
@@ -533,9 +538,10 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def incoming_tag_names_for_snapshot raw_tag_names
|
def incoming_tag_names_for_snapshot raw_tag_names
|
||||||
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
|
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
|
||||||
|
deny_deprecated: true)
|
||||||
|
|
||||||
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
|
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
||||||
@@ -622,13 +628,14 @@ class PostsController < ApplicationController
|
|||||||
original_created_from: snapshot[:original_created_from],
|
original_created_from: snapshot[:original_created_from],
|
||||||
original_created_before: snapshot[:original_created_before])
|
original_created_before: snapshot[:original_created_before])
|
||||||
|
|
||||||
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
|
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
|
||||||
|
deny_deprecated: true)
|
||||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
||||||
|
|
||||||
readonly_tags = post.tags.nico.to_a
|
readonly_tags = post.tags.nico.to_a
|
||||||
|
|
||||||
tags = readonly_tags + editable_tags
|
tags = readonly_tags + editable_tags
|
||||||
tags = Tag.expand_parent_tags(tags)
|
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||||
|
|
||||||
sync_post_tags!(post, tags)
|
sync_post_tags!(post, tags)
|
||||||
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class TagVersionsController < ApplicationController
|
|||||||
AND prev.version_no = tag_versions.version_no - 1
|
AND prev.version_no = tag_versions.version_no - 1
|
||||||
SQL
|
SQL
|
||||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||||
|
'prev.deprecated_at AS prev_deprecated_at',
|
||||||
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ class TagVersionsController < ApplicationController
|
|||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
name: { current: row.name, prev: row.attributes['prev_name'] },
|
name: { current: row.name, prev: row.attributes['prev_name'] },
|
||||||
category: { current: row.category, prev: row.attributes['prev_category'] },
|
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||||
|
deprecated_at: { current: row.deprecated_at&.iso8601,
|
||||||
|
prev: row.attributes['prev_deprecated_at']&.iso8601 },
|
||||||
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
||||||
parent_tags:,
|
parent_tags:,
|
||||||
created_at: row.created_at.iso8601,
|
created_at: row.created_at.iso8601,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
require 'net/http'
|
require 'net/http'
|
||||||
require 'uri'
|
require 'uri'
|
||||||
|
require 'set'
|
||||||
|
|
||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
@@ -14,6 +15,8 @@ class TagsController < ApplicationController
|
|||||||
post_count_between[1] = nil if post_count_between[1] < 0
|
post_count_between[1] = nil if post_count_between[1] < 0
|
||||||
created_between = params[:created_from].presence, params[:created_to].presence
|
created_between = params[:created_from].presence, params[:created_to].presence
|
||||||
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
||||||
|
deprecated_given = params.key?(:deprecated)
|
||||||
|
deprecated = bool?(:deprecated)
|
||||||
|
|
||||||
order = params[:order].to_s.split(':', 2).map(&:strip)
|
order = params[:order].to_s.split(':', 2).map(&:strip)
|
||||||
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
||||||
@@ -48,6 +51,9 @@ class TagsController < ApplicationController
|
|||||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
||||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||||
|
if deprecated_given
|
||||||
|
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
sort_sql =
|
sort_sql =
|
||||||
case order[0]
|
case order[0]
|
||||||
@@ -77,37 +83,27 @@ class TagsController < ApplicationController
|
|||||||
parent_tag_id = params[:parent].to_i
|
parent_tag_id = params[:parent].to_i
|
||||||
parent_tag_id = nil if parent_tag_id <= 0
|
parent_tag_id = nil if parent_tag_id <= 0
|
||||||
|
|
||||||
|
graph = build_with_depth_graph
|
||||||
|
|
||||||
tag_ids =
|
tag_ids =
|
||||||
if parent_tag_id
|
if parent_tag_id
|
||||||
TagImplication.where(parent_tag_id:).select(:tag_id)
|
visible_child_tag_ids(parent_tag_id, graph)
|
||||||
else
|
else
|
||||||
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
visible_root_tag_ids(graph)
|
||||||
end
|
end
|
||||||
|
|
||||||
tags =
|
tags =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
.where(category: [:meme, :character, :material])
|
|
||||||
.where(id: tag_ids)
|
.where(id: tag_ids)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
.distinct
|
.distinct
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
has_children_tag_ids =
|
|
||||||
if tags.empty?
|
|
||||||
[]
|
|
||||||
else
|
|
||||||
TagImplication
|
|
||||||
.joins(:tag)
|
|
||||||
.where(parent_tag_id: tags.map(&:id),
|
|
||||||
tags: { category: [:meme, :character, :material] })
|
|
||||||
.distinct
|
|
||||||
.pluck(:parent_tag_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: tags.map { |tag|
|
render json: tags.map { |tag|
|
||||||
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
|
||||||
|
children: [])
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,6 +129,7 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
base = Tag.joins(:tag_name)
|
base = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
|
.where(deprecated_at: nil)
|
||||||
base = base.where('tags.post_count > 0') if present_only
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
|
|
||||||
canonical_hit =
|
canonical_hit =
|
||||||
@@ -252,18 +249,24 @@ class TagsController < ApplicationController
|
|||||||
category = params[:category].to_s.strip
|
category = params[:category].to_s.strip
|
||||||
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
||||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||||
|
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
||||||
|
|
||||||
if name != tag.name &&
|
if (name != tag.name &&
|
||||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
|
||||||
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name)
|
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
||||||
end
|
|
||||||
|
|
||||||
if tag.nico? || category == 'nico'
|
|
||||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
alias_names = params[:aliases].to_s.split.uniq
|
alias_names = params[:aliases].to_s.split.uniq
|
||||||
parent_names = params[:parent_tags].to_s.split.uniq
|
parent_names = params[:parent_tags].to_s.split.uniq
|
||||||
|
deprecated = bool?(:deprecated)
|
||||||
|
|
||||||
|
if tag.nico? && deprecated
|
||||||
|
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||||
|
end
|
||||||
|
|
||||||
|
if tag.nico? || category == 'nico'
|
||||||
|
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||||
|
end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
@@ -272,7 +275,11 @@ class TagsController < ApplicationController
|
|||||||
name_changed = name != old_name
|
name_changed = name != old_name
|
||||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||||
|
|
||||||
|
if tag.deprecated? == deprecated
|
||||||
tag.update!(category:)
|
tag.update!(category:)
|
||||||
|
else
|
||||||
|
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
||||||
|
end
|
||||||
tag.tag_name.update!(name:)
|
tag.tag_name.update!(name:)
|
||||||
|
|
||||||
alias_names << old_name if name_changed
|
alias_names << old_name if name_changed
|
||||||
@@ -300,11 +307,17 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].presence
|
||||||
category = params[:category].presence
|
category = params[:category].presence
|
||||||
|
deprecated_given = params.key?(:deprecated)
|
||||||
|
deprecated = bool?(:deprecated)
|
||||||
|
|
||||||
tag = Tag.find(params[:id])
|
tag = Tag.find(params[:id])
|
||||||
|
|
||||||
|
if tag.nico? && deprecated_given && deprecated
|
||||||
|
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||||
|
end
|
||||||
|
|
||||||
if tag.nico? || (category.present? && category == 'nico')
|
if tag.nico? || (category.present? && category == 'nico')
|
||||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||||
end
|
end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
@@ -316,6 +329,9 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag.tag_name.update!(name:) if name.present?
|
tag.tag_name.update!(name:) if name.present?
|
||||||
tag.update!(category:) if category.present?
|
tag.update!(category:) if category.present?
|
||||||
|
if deprecated_given && tag.deprecated? != deprecated
|
||||||
|
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
||||||
|
end
|
||||||
|
|
||||||
tag.reload
|
tag.reload
|
||||||
|
|
||||||
@@ -332,18 +348,99 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def build_with_depth_graph
|
||||||
|
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
||||||
|
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
||||||
|
|
||||||
|
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
|
||||||
|
children_by_parent_id[parent_id] << child_id
|
||||||
|
parent_ids_by_child_id[child_id] << parent_id
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_ids = (children_by_parent_id.keys +
|
||||||
|
parent_ids_by_child_id.keys +
|
||||||
|
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
|
||||||
|
|
||||||
|
tags_by_id = Tag.where(id: tag_ids)
|
||||||
|
.pluck(:id, :category, :deprecated_at)
|
||||||
|
.each_with_object({ }) do |(id, category, deprecated_at), h|
|
||||||
|
h[id] = { category:, deprecated: deprecated_at.present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
||||||
|
visible_child_tag_ids_by_parent_id: { } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_root_tag_ids graph
|
||||||
|
graph[:tags_by_id].filter_map do |tag_id, attrs|
|
||||||
|
next unless with_depth_visible_tag?(attrs)
|
||||||
|
next unless visible_root_tag?(tag_id, graph)
|
||||||
|
|
||||||
|
tag_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_root_tag? tag_id, graph
|
||||||
|
seen = Set.new([tag_id])
|
||||||
|
stack = graph[:parent_ids_by_child_id][tag_id].dup
|
||||||
|
|
||||||
|
until stack.empty?
|
||||||
|
parent_id = stack.pop
|
||||||
|
next if seen.include?(parent_id)
|
||||||
|
|
||||||
|
seen << parent_id
|
||||||
|
|
||||||
|
parent = graph[:tags_by_id][parent_id]
|
||||||
|
next unless parent
|
||||||
|
|
||||||
|
return false unless parent[:deprecated]
|
||||||
|
|
||||||
|
stack.concat(graph[:parent_ids_by_child_id][parent_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_child_tag_ids parent_tag_id, graph
|
||||||
|
cache = graph[:visible_child_tag_ids_by_parent_id]
|
||||||
|
return cache[parent_tag_id] if cache.key?(parent_tag_id)
|
||||||
|
|
||||||
|
visible_ids = Set.new
|
||||||
|
|
||||||
|
graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id|
|
||||||
|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id]))
|
||||||
|
end
|
||||||
|
|
||||||
|
cache[parent_tag_id] = visible_ids.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen
|
||||||
|
return if seen.include?(tag_id)
|
||||||
|
|
||||||
|
seen = seen.dup << tag_id
|
||||||
|
tag = graph[:tags_by_id][tag_id]
|
||||||
|
return unless tag
|
||||||
|
|
||||||
|
if tag[:deprecated]
|
||||||
|
graph[:children_by_parent_id][tag_id].each do |child_tag_id|
|
||||||
|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
visible_ids << tag_id if with_depth_visible_tag?(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_depth_visible_tag? tag
|
||||||
|
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
||||||
|
end
|
||||||
|
|
||||||
def build_tag_children tag
|
def build_tag_children tag
|
||||||
material = tag.materials.first
|
material = tag.materials.first
|
||||||
file = nil
|
|
||||||
content_type = nil
|
|
||||||
if material&.file&.attached?
|
|
||||||
file = rails_storage_proxy_url(material.file, only_path: false)
|
|
||||||
content_type = material.file.blob.content_type
|
|
||||||
end
|
|
||||||
|
|
||||||
TagRepr.base(tag).merge(
|
TagRepr.base(tag).merge(
|
||||||
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
||||||
material: material.as_json&.merge(file:, content_type:))
|
material: material && MaterialRepr.base(material, host: request.base_url))
|
||||||
end
|
end
|
||||||
|
|
||||||
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
|
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ class WikiPagesController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if title.blank?
|
if title.blank?
|
||||||
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
|
return render json: WikiPageRepr.base(
|
||||||
|
WikiPage.joins(:tag_name).includes(tag_name: :tag))
|
||||||
end
|
end
|
||||||
|
|
||||||
q = WikiPage.joins(:tag_name).includes(:tag_name)
|
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
|
||||||
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
||||||
render json: WikiPageRepr.base(q.limit(20))
|
render json: WikiPageRepr.base(q.limit(20))
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(:tag_name)
|
.includes(tag_name: :tag)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -22,7 +23,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def show_by_title
|
def show_by_title
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(:tag_name)
|
.includes(tag_name: :tag)
|
||||||
.find_by(tag_name: { name: title })
|
.find_by(tag_name: { name: title })
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -51,7 +52,7 @@ class WikiPagesController < ApplicationController
|
|||||||
from = params[:from].presence
|
from = params[:from].presence
|
||||||
to = params[:to].presence
|
to = params[:to].presence
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id)
|
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||||
|
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
from_rev = from && page.wiki_revisions.find(from)
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||||
@@ -76,6 +77,7 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
render json: { wiki_page_id: page.id,
|
render json: { wiki_page_id: page.id,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
|
deprecated_at: page.deprecated_at,
|
||||||
older_revision_id: from_rev&.id,
|
older_revision_id: from_rev&.id,
|
||||||
newer_revision_id: to_rev.id,
|
newer_revision_id: to_rev.id,
|
||||||
diff: diff_json }
|
diff: diff_json }
|
||||||
@@ -157,7 +159,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def changes
|
def changes
|
||||||
id = params[:id].presence
|
id = params[:id].presence
|
||||||
q = WikiRevision.joins(wiki_page: :tag_name)
|
q = WikiRevision.joins(wiki_page: :tag_name)
|
||||||
.includes(:created_user, wiki_page: :tag_name)
|
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
q = q.where(wiki_page_id: id) if id
|
q = q.where(wiki_page_id: id) if id
|
||||||
|
|
||||||
@@ -165,7 +167,9 @@ class WikiPagesController < ApplicationController
|
|||||||
{ revision_id: rev.id,
|
{ revision_id: rev.id,
|
||||||
pred: rev.base_revision_id,
|
pred: rev.base_revision_id,
|
||||||
succ: nil,
|
succ: nil,
|
||||||
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title },
|
wiki_page: { id: rev.wiki_page_id,
|
||||||
|
title: rev.wiki_page.title,
|
||||||
|
deprecated_at: rev.wiki_page.deprecated_at },
|
||||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||||
kind: rev.kind,
|
kind: rev.kind,
|
||||||
message: rev.message,
|
message: rev.message,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class GekanatorAiRun < ApplicationRecord
|
||||||
|
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_question_suggestion
|
||||||
|
|
||||||
|
validates :model, presence: true, length: { maximum: 255 }
|
||||||
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
validates :input_tokens,
|
||||||
|
presence: true,
|
||||||
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :output_tokens,
|
||||||
|
presence: true,
|
||||||
|
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :estimated_cost_jpy,
|
||||||
|
presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|
||||||
|
scope :this_month, lambda {
|
||||||
|
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
|
||||||
|
}
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class GekanatorGame < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :guessed_post, class_name: 'Post'
|
||||||
|
belongs_to :correct_post, class_name: 'Post'
|
||||||
|
has_many :question_suggestions,
|
||||||
|
class_name: 'GekanatorQuestionSuggestion',
|
||||||
|
dependent: :delete_all
|
||||||
|
has_many :question_examples,
|
||||||
|
class_name: 'GekanatorQuestionExample',
|
||||||
|
dependent: :delete_all
|
||||||
|
|
||||||
|
validates :answers, presence: true
|
||||||
|
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :won, inclusion: { in: [true, false] }
|
||||||
|
end
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class GekanatorQuestion < ApplicationRecord
|
||||||
|
KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
|
||||||
|
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
|
||||||
|
STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_question_suggestion, optional: true
|
||||||
|
belongs_to :created_by, class_name: 'User', optional: true
|
||||||
|
has_many :gekanator_question_examples, dependent: :delete_all
|
||||||
|
|
||||||
|
validates :kind, presence: true, inclusion: { in: KINDS }
|
||||||
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||||
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
validates :text, presence: true, length: { maximum: 1000 }
|
||||||
|
validates :condition, presence: true
|
||||||
|
validates :priority_weight,
|
||||||
|
presence: true,
|
||||||
|
numericality: {
|
||||||
|
greater_than: 0,
|
||||||
|
less_than_or_equal_to: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :accepted, -> { where(status: 'accepted') }
|
||||||
|
end
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
class GekanatorQuestionExample < ApplicationRecord
|
||||||
|
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
|
||||||
|
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
|
||||||
|
SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_question
|
||||||
|
belongs_to :post
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :gekanator_game, optional: true
|
||||||
|
|
||||||
|
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||||
|
validates :answer_counts, presence: true
|
||||||
|
validates :sample_count,
|
||||||
|
presence: true,
|
||||||
|
numericality: {
|
||||||
|
only_integer: true,
|
||||||
|
greater_than: 0
|
||||||
|
}
|
||||||
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||||
|
validates :weight,
|
||||||
|
presence: true,
|
||||||
|
numericality: {
|
||||||
|
greater_than: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
before_validation :normalize_learning_state
|
||||||
|
|
||||||
|
def record_answer!(answer:, source:, gekanator_game: nil)
|
||||||
|
answer = answer.to_s
|
||||||
|
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
|
||||||
|
|
||||||
|
counts = normalized_answer_counts
|
||||||
|
counts[answer] += 1
|
||||||
|
|
||||||
|
self.answer_counts = counts
|
||||||
|
self.sample_count = counts.values.sum
|
||||||
|
self.gekanator_game = gekanator_game if gekanator_game.present?
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
apply_aggregated_answer!(preferred_answer: answer)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_learning_state
|
||||||
|
counts = normalized_answer_counts
|
||||||
|
|
||||||
|
if counts.values.sum.zero? && answer.present?
|
||||||
|
counts[answer] = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
self.answer_counts = counts
|
||||||
|
self.sample_count = counts.values.sum
|
||||||
|
|
||||||
|
apply_aggregated_answer!
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_aggregated_answer!(preferred_answer: nil)
|
||||||
|
counts = normalized_answer_counts
|
||||||
|
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
|
||||||
|
known_total = known_counts.values.sum
|
||||||
|
|
||||||
|
if known_total.zero?
|
||||||
|
self.answer = 'unknown'
|
||||||
|
self.weight = 0.1
|
||||||
|
return
|
||||||
|
else
|
||||||
|
max_count = known_counts.values.max
|
||||||
|
candidates = known_counts.select { |_answer, count| count == max_count }.keys
|
||||||
|
self.answer =
|
||||||
|
if preferred_answer.present? && candidates.include?(preferred_answer)
|
||||||
|
preferred_answer
|
||||||
|
elsif answer.present? && candidates.include?(answer)
|
||||||
|
answer
|
||||||
|
else
|
||||||
|
candidates.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
consensus = max_count.to_f / known_total
|
||||||
|
self.weight = Math.sqrt(known_total) * consensus
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_answer_counts
|
||||||
|
base = ANSWERS.index_with(0)
|
||||||
|
|
||||||
|
answer_counts.to_h.each do |key, value|
|
||||||
|
answer_key = key.to_s
|
||||||
|
next unless ANSWERS.include?(answer_key)
|
||||||
|
|
||||||
|
base[answer_key] = value.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
base
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||||
|
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
|
||||||
|
|
||||||
|
belongs_to :gekanator_game
|
||||||
|
belongs_to :user
|
||||||
|
has_many :gekanator_questions, dependent: :nullify
|
||||||
|
has_many :gekanator_ai_runs, dependent: :destroy
|
||||||
|
|
||||||
|
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||||
|
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||||
|
validates :processed, inclusion: { in: [true, false] }
|
||||||
|
end
|
||||||
@@ -9,6 +9,10 @@ class Material < ApplicationRecord
|
|||||||
belongs_to :tag, optional: true
|
belongs_to :tag, optional: true
|
||||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :file_suppressed_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
has_many :material_versions, dependent: :destroy
|
||||||
|
has_many :material_export_items, dependent: :destroy
|
||||||
|
|
||||||
has_one_attached :file, dependent: :purge
|
has_one_attached :file, dependent: :purge
|
||||||
|
|
||||||
@@ -18,11 +22,18 @@ class Material < ApplicationRecord
|
|||||||
validate :tag_must_be_material_category
|
validate :tag_must_be_material_category
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
|
return nil if file_suppressed?
|
||||||
return nil unless file&.attached?
|
return nil unless file&.attached?
|
||||||
|
|
||||||
file.blob.content_type
|
file.blob.content_type
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def file_suppressed? = file_suppressed_at.present?
|
||||||
|
|
||||||
|
def snapshot_export_paths
|
||||||
|
material_export_items.order(:profile).pluck(:profile, :export_path).to_h
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def file_must_be_attached
|
def file_must_be_attached
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
class MaterialExportItem < ApplicationRecord
|
||||||
|
VALID_PROFILES = ['legacy_drive'].freeze
|
||||||
|
|
||||||
|
belongs_to :material
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
validates :profile, presence: true, inclusion: { in: VALID_PROFILES }
|
||||||
|
validates :export_path, presence: true, uniqueness: { scope: :profile }
|
||||||
|
validates :material_id, uniqueness: { scope: :profile }
|
||||||
|
validate :export_path_must_be_relative_safe_path
|
||||||
|
|
||||||
|
scope :enabled, -> { where(enabled: true) }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def export_path_must_be_relative_safe_path
|
||||||
|
return if export_path.blank?
|
||||||
|
|
||||||
|
if export_path.start_with?('/')
|
||||||
|
errors.add(:export_path, '絶対パスは使へません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if export_path.match?(/\A[A-Za-z]:\//)
|
||||||
|
errors.add(:export_path, '絶対パスは使へません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if export_path.include?('\\')
|
||||||
|
errors.add(:export_path, '/ 区切りで指定してください.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if export_path.include?("\0")
|
||||||
|
errors.add(:export_path, 'NUL は使へません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
parts = export_path.split('/')
|
||||||
|
if export_path.include?('//')
|
||||||
|
errors.add(:export_path, '空の path segment は使へません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if parts.any? { |part| part.in?(['.', '..']) }
|
||||||
|
errors.add(:export_path, '.. は使へません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
if export_path.end_with?('/')
|
||||||
|
errors.add(:export_path, 'directory path は使へません.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
class MaterialImportBlock < ApplicationRecord
|
||||||
|
MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze
|
||||||
|
REASONS = [
|
||||||
|
'copyright_high_risk',
|
||||||
|
'copyright_takedown',
|
||||||
|
'adult_or_sensitive',
|
||||||
|
'personal_information',
|
||||||
|
'malware_or_dangerous_file',
|
||||||
|
'duplicate_or_low_quality',
|
||||||
|
'source_owner_request',
|
||||||
|
'other'
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS }
|
||||||
|
validates :reason, presence: true, inclusion: { in: REASONS }
|
||||||
|
validates :sha256, length: { is: 64 }, allow_blank: true
|
||||||
|
validate :match_value_must_be_present
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def match_value_must_be_present
|
||||||
|
return if match_kind == 'manual'
|
||||||
|
return if sha256.present? || external_path_pattern.present?
|
||||||
|
|
||||||
|
errors.add(:base, 'sha256 または external_path_pattern は必須です.')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class MaterialVersion < ApplicationRecord
|
||||||
|
EVENT_TYPE_MAP = { create: 'create',
|
||||||
|
update: 'update',
|
||||||
|
discard: 'discard',
|
||||||
|
restore: 'restore',
|
||||||
|
suppress: 'suppress' }.freeze
|
||||||
|
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :material
|
||||||
|
belongs_to :tag, optional: true
|
||||||
|
belongs_to :parent, class_name: 'Material', optional: true
|
||||||
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
def export_paths_hash
|
||||||
|
export_paths_json || {}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,10 +7,23 @@ class Post < ApplicationRecord
|
|||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
has_many :tags, through: :active_post_tags
|
has_many :tags, through: :active_post_tags
|
||||||
|
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
||||||
|
through: :active_post_tags, source: :tag
|
||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
has_many :post_versions
|
has_many :post_versions
|
||||||
|
has_many :gekanator_guessed_games,
|
||||||
|
class_name: 'GekanatorGame',
|
||||||
|
foreign_key: :guessed_post_id,
|
||||||
|
dependent: :delete_all,
|
||||||
|
inverse_of: :guessed_post
|
||||||
|
has_many :gekanator_correct_games,
|
||||||
|
class_name: 'GekanatorGame',
|
||||||
|
foreign_key: :correct_post_id,
|
||||||
|
dependent: :delete_all,
|
||||||
|
inverse_of: :correct_post
|
||||||
|
has_many :gekanator_question_examples, dependent: :delete_all
|
||||||
|
|
||||||
has_many :parent_post_implications,
|
has_many :parent_post_implications,
|
||||||
class_name: 'PostImplication',
|
class_name: 'PostImplication',
|
||||||
|
|||||||
+24
-1
@@ -8,6 +8,15 @@ class Tag < ApplicationRecord
|
|||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class DeprecatedTagNormalisationError < ArgumentError
|
||||||
|
attr_reader :tag_names
|
||||||
|
|
||||||
|
def initialize tag_names
|
||||||
|
@tag_names = Array(tag_names)
|
||||||
|
super('deprecated tags are not allowed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
has_many :post_tags, inverse_of: :tag
|
has_many :post_tags, inverse_of: :tag
|
||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
@@ -58,6 +67,7 @@ class Tag < ApplicationRecord
|
|||||||
validate :nico_tag_name_must_start_with_nico
|
validate :nico_tag_name_must_start_with_nico
|
||||||
validate :tag_name_must_be_canonical
|
validate :tag_name_must_be_canonical
|
||||||
validate :category_must_be_deerjikist_with_deerjikists
|
validate :category_must_be_deerjikist_with_deerjikists
|
||||||
|
validate :nico_tags_cannot_be_deprecated
|
||||||
|
|
||||||
scope :nico_tags, -> { nico }
|
scope :nico_tags, -> { nico }
|
||||||
|
|
||||||
@@ -77,6 +87,8 @@ class Tag < ApplicationRecord
|
|||||||
(self.tag_name ||= build_tag_name).name = val
|
(self.tag_name ||= build_tag_name).name = val
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deprecated? = deprecated_at?
|
||||||
|
|
||||||
def has_wiki = wiki_page.present?
|
def has_wiki = wiki_page.present?
|
||||||
|
|
||||||
def material_id = materials.first&.id
|
def material_id = materials.first&.id
|
||||||
@@ -92,7 +104,8 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def self.normalise_tags! tag_names, with_tagme: true,
|
def self.normalise_tags! tag_names, with_tagme: true,
|
||||||
with_no_deerjikist: true,
|
with_no_deerjikist: true,
|
||||||
deny_nico: true
|
deny_nico: true,
|
||||||
|
deny_deprecated: false
|
||||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||||
raise NicoTagNormalisationError
|
raise NicoTagNormalisationError
|
||||||
end
|
end
|
||||||
@@ -101,6 +114,10 @@ class Tag < ApplicationRecord
|
|||||||
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
||||||
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
|
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
|
||||||
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
||||||
|
if deny_deprecated && tag.deprecated?
|
||||||
|
raise DeprecatedTagNormalisationError, [tag.name]
|
||||||
|
end
|
||||||
|
|
||||||
tag.update!(category: cat) if cat && tag.category != cat
|
tag.update!(category: cat) if cat && tag.category != cat
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -228,4 +245,10 @@ class Tag < ApplicationRecord
|
|||||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def nico_tags_cannot_be_deprecated
|
||||||
|
if nico? && deprecated_at.present?
|
||||||
|
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
module VersionRecord
|
module VersionRecord
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
|
||||||
|
update: 'update',
|
||||||
|
discard: 'discard',
|
||||||
|
restore: 'restore' }.freeze
|
||||||
|
|
||||||
def readonly? = persisted?
|
def readonly? = persisted?
|
||||||
|
|
||||||
included do
|
included do
|
||||||
|
event_type_map = if const_defined?(:EVENT_TYPE_MAP, false)
|
||||||
|
const_get(:EVENT_TYPE_MAP)
|
||||||
|
else
|
||||||
|
DEFAULT_EVENT_TYPE_MAP
|
||||||
|
end
|
||||||
|
|
||||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
enum :event_type, { create: 'create',
|
enum :event_type, event_type_map, prefix: true, validate: true
|
||||||
update: 'update',
|
|
||||||
discard: 'discard',
|
|
||||||
restore: 'restore' }, prefix: true, validate: true
|
|
||||||
|
|
||||||
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
validates :event_type, presence: true
|
validates :event_type, presence: true
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class WikiPage < ApplicationRecord
|
|||||||
validates :body, presence: true
|
validates :body, presence: true
|
||||||
|
|
||||||
def title = tag_name.name
|
def title = tag_name.name
|
||||||
|
def deprecated_at = tag_name.tag&.deprecated_at
|
||||||
|
|
||||||
def title= val
|
def title= val
|
||||||
(self.tag_name ||= build_tag_name).name = val
|
(self.tag_name ||= build_tag_name).name = val
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
|
|
||||||
module MaterialRepr
|
module MaterialRepr
|
||||||
BASE = { only: [:id, :url, :created_at, :updated_at],
|
BASE = { only: [:id, :url, :version_no, :file_suppressed_at,
|
||||||
|
:file_suppression_reason, :created_at, :updated_at],
|
||||||
methods: [:content_type],
|
methods: [:content_type],
|
||||||
include: { tag: TagRepr::BASE,
|
include: { tag: TagRepr::BASE,
|
||||||
created_by_user: UserRepr::BASE,
|
created_by_user: UserRepr::BASE,
|
||||||
@@ -12,13 +13,30 @@ module MaterialRepr
|
|||||||
|
|
||||||
def base material, host:
|
def base material, host:
|
||||||
material.as_json(BASE).merge(
|
material.as_json(BASE).merge(
|
||||||
file: if material.file.attached?
|
file: if material.file.attached? && !material.file_suppressed?
|
||||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||||
material.file, host:)
|
material.file, host:)
|
||||||
end)
|
end,
|
||||||
|
export_paths: export_paths(material),
|
||||||
|
export_items: export_items(material))
|
||||||
end
|
end
|
||||||
|
|
||||||
def many materials, host:
|
def many materials, host:
|
||||||
materials.map { |m| base(m, host:) }
|
materials.map { |m| base(m, host:) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def export_paths material
|
||||||
|
material.material_export_items.each_with_object({ }) do |item, hash|
|
||||||
|
hash[item.profile] = item.enabled ? item.export_path : ''
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def export_items material
|
||||||
|
material.material_export_items.map do |item|
|
||||||
|
{ id: item.id,
|
||||||
|
profile: item.profile,
|
||||||
|
export_path: item.export_path,
|
||||||
|
enabled: item.enabled }
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ module PostRepr
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tag_json tags
|
def tag_json tags
|
||||||
tags.map { |tag| TagRepr.inline(tag) }
|
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_url post
|
def thumbnail_url post
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
module TagRepr
|
module TagRepr
|
||||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||||
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
module WikiPageRepr
|
module WikiPageRepr
|
||||||
BASE = { methods: [:title] }.freeze
|
BASE = { methods: [:title, :deprecated_at] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module Gekanator
|
||||||
|
class AiRunBudget
|
||||||
|
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
|
||||||
|
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
|
||||||
|
|
||||||
|
def self.remaining_monthly_budget_jpy
|
||||||
|
MONTHLY_LIMIT_JPY - monthly_cost_jpy
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.monthly_cost_jpy
|
||||||
|
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.exceeded?
|
||||||
|
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.exceeded_after_next_run?
|
||||||
|
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
module Gekanator
|
||||||
|
class QuestionSuggestionAiConverter
|
||||||
|
# Temporary heuristic converter for #361.
|
||||||
|
# This creates pending ai_generated questions without external LLM calls;
|
||||||
|
# accepted questions are still distributed only after admin approval.
|
||||||
|
TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/
|
||||||
|
ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/
|
||||||
|
ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/
|
||||||
|
ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/
|
||||||
|
TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/
|
||||||
|
SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/
|
||||||
|
|
||||||
|
def self.call(...) = new(...).call
|
||||||
|
|
||||||
|
def initialize suggestion:, user:
|
||||||
|
@suggestion = suggestion
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
suggestion.with_lock do
|
||||||
|
existing = existing_generated_question
|
||||||
|
return existing if existing
|
||||||
|
|
||||||
|
run = suggestion.gekanator_ai_runs.create!(
|
||||||
|
model: 'heuristic_converter_v1',
|
||||||
|
status: 'running',
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
estimated_cost_jpy: 0)
|
||||||
|
|
||||||
|
question_attributes = build_question
|
||||||
|
question =
|
||||||
|
question_attributes &&
|
||||||
|
GekanatorQuestion.create!(
|
||||||
|
**question_attributes,
|
||||||
|
source: 'ai_generated',
|
||||||
|
status: 'pending',
|
||||||
|
gekanator_question_suggestion: suggestion,
|
||||||
|
created_by: user)
|
||||||
|
|
||||||
|
run.update!(status: question ? 'succeeded' : 'failed')
|
||||||
|
question
|
||||||
|
end
|
||||||
|
rescue => error
|
||||||
|
run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed'
|
||||||
|
raise error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :suggestion, :user
|
||||||
|
|
||||||
|
def existing_generated_question
|
||||||
|
suggestion
|
||||||
|
.gekanator_questions
|
||||||
|
.where(source: 'ai_generated')
|
||||||
|
.order(id: :desc)
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_question
|
||||||
|
text = normalized_text
|
||||||
|
return nil if text.blank?
|
||||||
|
|
||||||
|
structured_question_for(text) || post_similarity_question_for(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_text
|
||||||
|
suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def structured_question_for text
|
||||||
|
case text
|
||||||
|
when TITLE_LENGTH_RE
|
||||||
|
length = Regexp.last_match(1).to_i
|
||||||
|
return nil if length <= 0
|
||||||
|
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'title',
|
||||||
|
condition: {
|
||||||
|
type: 'title-length-at-least',
|
||||||
|
length:
|
||||||
|
},
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when /\A題名に英数字が混じって[ゐい]る[??]\z/
|
||||||
|
{
|
||||||
|
text: '題名に英数字が混じってゐる?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: { type: 'title-has-ascii' },
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when ORIGINAL_YEAR_RE
|
||||||
|
year = Regexp.last_match(1).to_i
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'original_date',
|
||||||
|
condition: { type: 'original-year', year: },
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when ORIGINAL_MONTH_RE
|
||||||
|
month = Regexp.last_match(1).to_i
|
||||||
|
return nil unless month.between?(1, 12)
|
||||||
|
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'original_date',
|
||||||
|
condition: { type: 'original-month', month: },
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when ORIGINAL_MONTH_DAY_RE
|
||||||
|
month = Regexp.last_match(1).to_i
|
||||||
|
day = Regexp.last_match(2).to_i
|
||||||
|
return nil unless month.between?(1, 12) && day.between?(1, 31)
|
||||||
|
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'original_date',
|
||||||
|
condition: {
|
||||||
|
type: 'original-month-day',
|
||||||
|
monthDay: "#{ month }-#{ day }"
|
||||||
|
},
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when TITLE_CONTAINS_RE
|
||||||
|
title_text = Regexp.last_match(1).to_s.strip
|
||||||
|
return nil if title_text.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
text: "題名に「#{ title_text }」が含まれる?",
|
||||||
|
kind: 'title',
|
||||||
|
condition: { type: 'title-contains', text: title_text },
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
when SOURCE_RE
|
||||||
|
host = Regexp.last_match(1).to_s.strip
|
||||||
|
return nil if host.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'source',
|
||||||
|
condition: { type: 'source', host: },
|
||||||
|
priority_weight: 0.95
|
||||||
|
}
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_similarity_question_for text
|
||||||
|
return nil if suggestion.answer == 'unknown'
|
||||||
|
|
||||||
|
{
|
||||||
|
text:,
|
||||||
|
kind: 'post_similarity',
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: suggestion.gekanator_game.correct_post_id,
|
||||||
|
answer: suggestion.answer,
|
||||||
|
threshold: 0.65
|
||||||
|
},
|
||||||
|
priority_weight: 1.0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
module Gekanator
|
||||||
|
class QuestionSuggestionPromoter
|
||||||
|
def self.call(...) = new(...).call
|
||||||
|
|
||||||
|
def initialize suggestion:, user:
|
||||||
|
@suggestion = suggestion
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
suggestion.with_lock do
|
||||||
|
return promoted_question if suggestion.processed?
|
||||||
|
return suggestion if suggestion.answer == 'unknown'
|
||||||
|
|
||||||
|
question = GekanatorQuestion.create!(
|
||||||
|
text: suggestion.question_text,
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
status: 'accepted',
|
||||||
|
priority_weight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: suggestion.gekanator_game.correct_post_id,
|
||||||
|
answer: suggestion.answer,
|
||||||
|
threshold: 0.65
|
||||||
|
},
|
||||||
|
gekanator_question_suggestion: suggestion,
|
||||||
|
created_by: user)
|
||||||
|
example =
|
||||||
|
GekanatorQuestionExample.new(
|
||||||
|
gekanator_question: question,
|
||||||
|
post: suggestion.gekanator_game.correct_post,
|
||||||
|
user: user)
|
||||||
|
example.record_answer!(
|
||||||
|
answer: suggestion.answer,
|
||||||
|
source: 'initial_suggestion',
|
||||||
|
gekanator_game: suggestion.gekanator_game)
|
||||||
|
example.save!
|
||||||
|
suggestion.update!(processed: true)
|
||||||
|
question
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :suggestion, :user
|
||||||
|
|
||||||
|
def promoted_question
|
||||||
|
suggestion.gekanator_questions.order(id: :desc).first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
require 'digest'
|
||||||
|
|
||||||
|
class MaterialFileSha256
|
||||||
|
def self.from_blob blob, allow_download: false
|
||||||
|
sha256 = blob.metadata['sha256']
|
||||||
|
return sha256 if sha256.present?
|
||||||
|
return nil unless allow_download
|
||||||
|
|
||||||
|
begin
|
||||||
|
blob.open do |file|
|
||||||
|
sha256 = Digest::SHA256.file(file.path).hexdigest
|
||||||
|
blob.metadata['sha256'] = sha256
|
||||||
|
blob.save! if blob.changed?
|
||||||
|
sha256
|
||||||
|
end
|
||||||
|
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
|
||||||
|
Rails.logger.warn(
|
||||||
|
"MaterialFileSha256.from_blob failed for blob_id=#{blob.id}: " \
|
||||||
|
"#{error.class}: #{error.message}",
|
||||||
|
)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_upload upload
|
||||||
|
tempfile = upload&.tempfile
|
||||||
|
return nil unless tempfile
|
||||||
|
|
||||||
|
tempfile.rewind
|
||||||
|
Digest::SHA256.file(tempfile.path).hexdigest.tap do
|
||||||
|
tempfile.rewind
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class MaterialImportBlockMatcher
|
||||||
|
def self.match_for_sha256 sha256
|
||||||
|
return nil if sha256.blank?
|
||||||
|
|
||||||
|
MaterialImportBlock.find_by(match_kind: 'sha256', sha256:)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
class MaterialVersionRecorder < VersionRecorder
|
||||||
|
EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze
|
||||||
|
|
||||||
|
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
|
||||||
|
new(material:, event_type:, created_by_user:, file_snapshot:).record!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
|
||||||
|
@file_snapshot = file_snapshot
|
||||||
|
|
||||||
|
super(record: material, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ensure_snapshot! material, created_by_user:
|
||||||
|
return if material.material_versions.exists?
|
||||||
|
|
||||||
|
record!(material:, event_type: :create,
|
||||||
|
created_by_user: material.created_by_user || created_by_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def version_class = MaterialVersion
|
||||||
|
def version_association = :material_versions
|
||||||
|
def record_key = :material
|
||||||
|
|
||||||
|
def snapshot_attributes
|
||||||
|
blob = @record.file.attached? ? @record.file.blob : nil
|
||||||
|
file_snapshot = build_file_snapshot(blob)
|
||||||
|
|
||||||
|
{ url: @record.url,
|
||||||
|
parent: @record.parent,
|
||||||
|
tag: @record.tag,
|
||||||
|
tag_name: @record.tag&.name,
|
||||||
|
tag_category: @record.tag&.category,
|
||||||
|
export_paths_json: @record.snapshot_export_paths,
|
||||||
|
discarded_at: @record.discarded_at,
|
||||||
|
file_blob_id: file_snapshot[:file_blob_id],
|
||||||
|
file_filename: file_snapshot[:file_filename],
|
||||||
|
file_content_type: file_snapshot[:file_content_type],
|
||||||
|
file_byte_size: file_snapshot[:file_byte_size],
|
||||||
|
file_checksum: file_snapshot[:file_checksum],
|
||||||
|
file_sha256: file_snapshot[:file_sha256],
|
||||||
|
file_suppressed_at: @record.file_suppressed_at,
|
||||||
|
file_suppression_reason: @record.file_suppression_reason }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_file_snapshot blob
|
||||||
|
return @file_snapshot if @file_snapshot
|
||||||
|
return empty_file_snapshot unless blob
|
||||||
|
|
||||||
|
{ file_blob_id: blob.id,
|
||||||
|
file_filename: blob.filename.to_s,
|
||||||
|
file_content_type: blob.content_type,
|
||||||
|
file_byte_size: blob.byte_size,
|
||||||
|
file_checksum: blob.checksum,
|
||||||
|
file_sha256: blob.metadata['sha256'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_file_snapshot
|
||||||
|
{ file_blob_id: nil,
|
||||||
|
file_filename: nil,
|
||||||
|
file_content_type: nil,
|
||||||
|
file_byte_size: nil,
|
||||||
|
file_checksum: nil,
|
||||||
|
file_sha256: nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_types = self.class::EVENT_TYPES
|
||||||
|
end
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
require 'stringio'
|
||||||
|
require 'zlib'
|
||||||
|
|
||||||
|
# Initial implementation keeps every file payload and the final ZIP in memory.
|
||||||
|
# Keep this service boundary stable so job/cached export paths can replace it later.
|
||||||
|
class MaterialZipExporter
|
||||||
|
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
|
||||||
|
MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)
|
||||||
|
|
||||||
|
class EmptyExportError < StandardError; end
|
||||||
|
class DuplicatePathError < StandardError; end
|
||||||
|
class MissingFileError < StandardError
|
||||||
|
attr_reader :missing_files
|
||||||
|
|
||||||
|
def initialize missing_files
|
||||||
|
@missing_files = missing_files
|
||||||
|
|
||||||
|
super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize profile: 'legacy_drive', tag_id: nil
|
||||||
|
@profile = profile.presence || 'legacy_drive'
|
||||||
|
@tag_id = tag_id.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def export
|
||||||
|
entries = build_entries
|
||||||
|
raise EmptyExportError if entries.empty?
|
||||||
|
|
||||||
|
ZipWriter.write(entries)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_entries
|
||||||
|
rows = MaterialExportItem
|
||||||
|
.enabled
|
||||||
|
.includes(material: { file_attachment: :blob })
|
||||||
|
.joins(:material)
|
||||||
|
.merge(Material.kept)
|
||||||
|
.where(profile: @profile)
|
||||||
|
.where(materials: { file_suppressed_at: nil })
|
||||||
|
.order(:export_path)
|
||||||
|
|
||||||
|
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
|
||||||
|
|
||||||
|
missing_files = []
|
||||||
|
|
||||||
|
entries = rows.filter_map do |item|
|
||||||
|
material = item.material
|
||||||
|
next unless material.file.attached?
|
||||||
|
|
||||||
|
data = download_blob(item, missing_files)
|
||||||
|
next unless data
|
||||||
|
|
||||||
|
Entry.new(path: item.export_path,
|
||||||
|
data:,
|
||||||
|
mtime: material.updated_at || Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
raise MissingFileError.new(missing_files) if missing_files.any?
|
||||||
|
|
||||||
|
paths = entries.map(&:path)
|
||||||
|
duplicated = paths.find { |path| paths.count(path) > 1 }
|
||||||
|
raise DuplicatePathError, duplicated if duplicated
|
||||||
|
|
||||||
|
entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_blob item, missing_files
|
||||||
|
blob = item.material.file.blob
|
||||||
|
|
||||||
|
blob.download
|
||||||
|
rescue ActiveStorage::FileNotFoundError
|
||||||
|
missing_files << MissingFile.new(
|
||||||
|
material_id: item.material_id,
|
||||||
|
export_path: item.export_path,
|
||||||
|
blob_id: blob.id,
|
||||||
|
filename: blob.filename.to_s,
|
||||||
|
)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
class ZipWriter
|
||||||
|
VERSION_NEEDED = 20
|
||||||
|
GP_FLAG = 0x0800
|
||||||
|
COMPRESSION_STORE = 0
|
||||||
|
|
||||||
|
def self.write entries
|
||||||
|
new(entries).write
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize entries
|
||||||
|
@entries = entries
|
||||||
|
@central_directory = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def write
|
||||||
|
io = StringIO.new(''.b)
|
||||||
|
|
||||||
|
@entries.each do |entry|
|
||||||
|
write_entry(io, entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
central_start = io.pos
|
||||||
|
@central_directory.each { |header| io.write(header) }
|
||||||
|
central_size = io.pos - central_start
|
||||||
|
|
||||||
|
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
|
||||||
|
central_size, central_start, 0].pack('VvvvvVVv'))
|
||||||
|
io.string
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def write_entry io, entry
|
||||||
|
path = entry.path.b
|
||||||
|
data = entry.data.b
|
||||||
|
crc32 = Zlib.crc32(data)
|
||||||
|
dos_time, dos_date = dos_timestamp(entry.mtime)
|
||||||
|
offset = io.pos
|
||||||
|
|
||||||
|
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
||||||
|
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
|
||||||
|
path.bytesize, 0].pack('VvvvvvVVVvv')
|
||||||
|
|
||||||
|
io.write(local_header)
|
||||||
|
io.write(path)
|
||||||
|
io.write(data)
|
||||||
|
|
||||||
|
@central_directory << central_header(path:, crc32:, size: data.bytesize,
|
||||||
|
dos_time:, dos_date:, offset:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
|
||||||
|
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
||||||
|
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
|
||||||
|
offset].pack('VvvvvvvVVVvvvvvVV') + path
|
||||||
|
end
|
||||||
|
|
||||||
|
def dos_timestamp time
|
||||||
|
local = time.to_time
|
||||||
|
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
|
||||||
|
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
|
||||||
|
[dos_time, dos_date]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module Similarity
|
module Similarity
|
||||||
class Calc
|
class Calc
|
||||||
def self.call model, tgt
|
def self.call model, tgt, scope: nil
|
||||||
similarity_model = "#{ model.name }Similarity".constantize
|
similarity_model = "#{ model.name }Similarity".constantize
|
||||||
|
|
||||||
# 最大保存件数
|
# 最大保存件数
|
||||||
@@ -8,7 +8,8 @@ module Similarity
|
|||||||
|
|
||||||
similarity_model.delete_all
|
similarity_model.delete_all
|
||||||
|
|
||||||
posts = model.includes(tgt).select(:id).to_a
|
scope ||= model.all
|
||||||
|
posts = scope.includes(tgt).select(:id).to_a
|
||||||
|
|
||||||
tag_ids = { }
|
tag_ids = { }
|
||||||
tag_cnts = { }
|
tag_cnts = { }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder
|
|||||||
def snapshot_attributes
|
def snapshot_attributes
|
||||||
{ name: @record.name,
|
{ name: @record.name,
|
||||||
category: @record.category,
|
category: @record.category,
|
||||||
|
deprecated_at: @record.deprecated_at,
|
||||||
aliases: @record.snapshot_aliases.join(' '),
|
aliases: @record.snapshot_aliases.join(' '),
|
||||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class VersionRecorder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_event_type!
|
def validate_event_type!
|
||||||
return if EVENT_TYPES.include?(@event_type)
|
return if event_types.include?(@event_type)
|
||||||
|
|
||||||
raise ArgumentError, "Invalid event_type: #{ @event_type }"
|
raise ArgumentError, "Invalid event_type: #{ @event_type }"
|
||||||
end
|
end
|
||||||
@@ -84,4 +84,5 @@ class VersionRecorder
|
|||||||
def snapshot_attributes = raise NotImplementedError
|
def snapshot_attributes = raise NotImplementedError
|
||||||
|
|
||||||
def record_class = @record.class
|
def record_class = @record.class
|
||||||
|
def event_types = self.class::EVENT_TYPES
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-1
@@ -63,6 +63,24 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :gekanator do
|
||||||
|
resources :games, only: [:create], controller: '/gekanator_games' do
|
||||||
|
member do
|
||||||
|
get :extra_questions
|
||||||
|
post :extra_question_answers
|
||||||
|
end
|
||||||
|
end
|
||||||
|
resources :posts, only: [:index], controller: '/gekanator_posts'
|
||||||
|
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||||
|
resources :question_suggestions,
|
||||||
|
only: [:create],
|
||||||
|
controller: '/gekanator_question_suggestions' do
|
||||||
|
member do
|
||||||
|
post :ai_convert
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
@@ -95,5 +113,10 @@ Rails.application.routes.draw do
|
|||||||
resources :skip_events, controller: :theatre_skip_events, only: [:index]
|
resources :skip_events, controller: :theatre_skip_events, only: [:index]
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :materials, only: [:index, :show, :create, :update, :destroy]
|
get 'materials/download.zip', to: 'materials#download'
|
||||||
|
resources :materials, only: [:index, :show, :create, :update, :destroy] do
|
||||||
|
member do
|
||||||
|
patch :suppress_file
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_games do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :guessed_post, null: false, foreign_key: { to_table: :posts }
|
||||||
|
t.references :correct_post, null: false, foreign_key: { to_table: :posts }
|
||||||
|
t.boolean :won, null: false
|
||||||
|
t.integer :question_count, null: false
|
||||||
|
t.json :answers, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_check_constraint :gekanator_games,
|
||||||
|
'question_count >= 0',
|
||||||
|
name: 'chk_gekanator_games_question_count_nonnegative'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_question_suggestions do |t|
|
||||||
|
t.references :gekanator_game,
|
||||||
|
null: false,
|
||||||
|
foreign_key: { on_delete: :cascade }
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.text :question_text, null: false
|
||||||
|
t.boolean :processed, null: false, default: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :gekanator_question_suggestions, :answer, :string, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_questions do |t|
|
||||||
|
t.string :text, null: false
|
||||||
|
t.string :kind, null: false
|
||||||
|
t.json :condition, null: false
|
||||||
|
t.string :source, null: false, default: 'ai_generated'
|
||||||
|
t.string :status, null: false, default: 'pending'
|
||||||
|
t.float :priority_weight, null: false, default: 1.0
|
||||||
|
t.references :gekanator_question_suggestion,
|
||||||
|
null: true,
|
||||||
|
foreign_key: true
|
||||||
|
t.references :created_by,
|
||||||
|
null: true,
|
||||||
|
foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_ai_runs do |t|
|
||||||
|
t.string :model, null: false
|
||||||
|
t.integer :input_tokens, null: false, default: 0
|
||||||
|
t.integer :output_tokens, null: false, default: 0
|
||||||
|
t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
|
||||||
|
t.string :status, null: false, default: 'pending'
|
||||||
|
t.references :gekanator_question_suggestion, null: false, foreign_key: true
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_question_examples do |t|
|
||||||
|
t.references :gekanator_question, null: false, foreign_key: true
|
||||||
|
t.references :post, null: false, foreign_key: true
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :gekanator_game, null: true, foreign_key: true
|
||||||
|
t.string :answer, null: false
|
||||||
|
t.string :source, null: false, default: 'post_game_extra'
|
||||||
|
t.float :weight, null: false, default: 1.0
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :gekanator_question_examples,
|
||||||
|
[:gekanator_question_id, :post_id, :user_id],
|
||||||
|
unique: true,
|
||||||
|
name: 'idx_gekanator_question_examples_on_question_post_user'
|
||||||
|
end
|
||||||
|
end
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
|
||||||
|
class MigrationGekanatorQuestionExample < ApplicationRecord
|
||||||
|
self.table_name = 'gekanator_question_examples'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
add_column :gekanator_question_examples,
|
||||||
|
:answer_counts,
|
||||||
|
:json,
|
||||||
|
null: true
|
||||||
|
add_column :gekanator_question_examples,
|
||||||
|
:sample_count,
|
||||||
|
:integer,
|
||||||
|
null: false,
|
||||||
|
default: 1
|
||||||
|
|
||||||
|
MigrationGekanatorQuestionExample.reset_column_information
|
||||||
|
MigrationGekanatorQuestionExample.find_each do |example|
|
||||||
|
counts = {
|
||||||
|
'yes' => 0,
|
||||||
|
'no' => 0,
|
||||||
|
'partial' => 0,
|
||||||
|
'probably_no' => 0,
|
||||||
|
'unknown' => 0
|
||||||
|
}
|
||||||
|
counts[example.answer] = 1 if counts.key?(example.answer)
|
||||||
|
|
||||||
|
example.update_columns(
|
||||||
|
answer_counts: counts,
|
||||||
|
sample_count: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
change_column_null :gekanator_question_examples, :answer_counts, false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :gekanator_question_examples, :sample_count
|
||||||
|
remove_column :gekanator_question_examples, :answer_counts
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class AddDeprecatedAtToTags < ActiveRecord::Migration[8.0]
|
||||||
|
def up
|
||||||
|
add_column :tags, :deprecated_at, :datetime, after: :category
|
||||||
|
add_column :tag_versions, :deprecated_at, :datetime, after: :parent_tag_ids
|
||||||
|
|
||||||
|
add_index :tags, :deprecated_at
|
||||||
|
|
||||||
|
add_check_constraint :tags, "deprecated_at IS NULL OR category <> 'nico'",
|
||||||
|
name: 'chk_tags_deprecated_at_not_nico'
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_check_constraint :tags, name: 'chk_tags_deprecated_at_not_nico'
|
||||||
|
|
||||||
|
remove_index :tags, :deprecated_at
|
||||||
|
|
||||||
|
remove_column :tag_versions, :deprecated_at, :datetime
|
||||||
|
remove_column :tags, :deprecated_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
|
||||||
|
def up
|
||||||
|
change_table :materials, bulk: true do |t|
|
||||||
|
t.integer :version_no, null: false, default: 1
|
||||||
|
t.datetime :file_suppressed_at
|
||||||
|
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.string :file_suppression_reason
|
||||||
|
end
|
||||||
|
|
||||||
|
change_table :material_versions, bulk: true do |t|
|
||||||
|
t.string :event_type
|
||||||
|
t.string :tag_name
|
||||||
|
t.string :tag_category
|
||||||
|
t.json :export_paths_json
|
||||||
|
t.bigint :file_blob_id
|
||||||
|
t.string :file_filename
|
||||||
|
t.string :file_content_type
|
||||||
|
t.bigint :file_byte_size
|
||||||
|
t.string :file_checksum
|
||||||
|
t.string :file_sha256
|
||||||
|
t.datetime :file_suppressed_at
|
||||||
|
t.string :file_suppression_reason
|
||||||
|
end
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
UPDATE material_versions
|
||||||
|
SET event_type = CASE
|
||||||
|
WHEN version_no = 1 THEN 'create'
|
||||||
|
ELSE 'update'
|
||||||
|
END
|
||||||
|
WHERE event_type IS NULL
|
||||||
|
SQL
|
||||||
|
|
||||||
|
change_column_null :material_versions, :event_type, false
|
||||||
|
add_index :material_versions, :file_blob_id
|
||||||
|
|
||||||
|
add_check_constraint :material_versions,
|
||||||
|
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')",
|
||||||
|
name: 'material_versions_event_type_valid'
|
||||||
|
|
||||||
|
create_table :material_export_items do |t|
|
||||||
|
t.references :material, null: false, foreign_key: true
|
||||||
|
t.string :profile, null: false, default: 'legacy_drive'
|
||||||
|
t.string :export_path, null: false
|
||||||
|
t.boolean :enabled, null: false, default: true
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
t.index [:profile, :export_path], unique: true
|
||||||
|
t.index [:material_id, :profile], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :material_import_blocks do |t|
|
||||||
|
t.string :match_kind, null: false
|
||||||
|
t.string :sha256
|
||||||
|
t.string :external_path_pattern
|
||||||
|
t.string :reason, null: false
|
||||||
|
t.text :note
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
UPDATE materials
|
||||||
|
SET version_no = COALESCE(
|
||||||
|
(SELECT MAX(material_versions.version_no)
|
||||||
|
FROM material_versions
|
||||||
|
WHERE material_versions.material_id = materials.id),
|
||||||
|
1)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :material_import_blocks
|
||||||
|
drop_table :material_export_items
|
||||||
|
|
||||||
|
remove_check_constraint :material_versions, name: 'material_versions_event_type_valid'
|
||||||
|
|
||||||
|
remove_index :material_versions, :file_blob_id
|
||||||
|
remove_column :material_versions, :event_type
|
||||||
|
remove_column :material_versions, :tag_name
|
||||||
|
remove_column :material_versions, :tag_category
|
||||||
|
remove_column :material_versions, :export_paths_json
|
||||||
|
remove_column :material_versions, :file_blob_id
|
||||||
|
remove_column :material_versions, :file_filename
|
||||||
|
remove_column :material_versions, :file_content_type
|
||||||
|
remove_column :material_versions, :file_byte_size
|
||||||
|
remove_column :material_versions, :file_checksum
|
||||||
|
remove_column :material_versions, :file_sha256
|
||||||
|
remove_column :material_versions, :file_suppressed_at
|
||||||
|
remove_column :material_versions, :file_suppression_reason
|
||||||
|
|
||||||
|
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
|
||||||
|
remove_column :materials, :version_no
|
||||||
|
remove_column :materials, :file_suppressed_at
|
||||||
|
remove_column :materials, :file_suppression_reason
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+139
-1
@@ -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_06_06_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_23_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
|
||||||
@@ -48,6 +48,79 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "model", null: false
|
||||||
|
t.integer "input_tokens", default: 0, null: false
|
||||||
|
t.integer "output_tokens", default: 0, null: false
|
||||||
|
t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.bigint "gekanator_question_suggestion_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.bigint "guessed_post_id", null: false
|
||||||
|
t.bigint "correct_post_id", null: false
|
||||||
|
t.boolean "won", null: false
|
||||||
|
t.integer "question_count", null: false
|
||||||
|
t.json "answers", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
|
||||||
|
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
||||||
|
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
|
||||||
|
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "gekanator_question_id", null: false
|
||||||
|
t.bigint "post_id", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.bigint "gekanator_game_id"
|
||||||
|
t.string "answer", null: false
|
||||||
|
t.string "source", default: "post_game_extra", null: false
|
||||||
|
t.float "weight", default: 1.0, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.json "answer_counts", null: false
|
||||||
|
t.integer "sample_count", default: 1, null: false
|
||||||
|
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
|
||||||
|
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
|
||||||
|
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
|
||||||
|
t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id"
|
||||||
|
t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "gekanator_game_id", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.text "question_text", null: false
|
||||||
|
t.boolean "processed", default: false, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "answer", null: false
|
||||||
|
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
|
||||||
|
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "text", null: false
|
||||||
|
t.string "kind", null: false
|
||||||
|
t.json "condition", null: false
|
||||||
|
t.string "source", default: "ai_generated", null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.float "priority_weight", default: 1.0, null: false
|
||||||
|
t.bigint "gekanator_question_suggestion_id"
|
||||||
|
t.bigint "created_by_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
|
||||||
|
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.binary "ip_address", limit: 16, null: false
|
t.binary "ip_address", limit: 16, null: false
|
||||||
t.datetime "banned_at"
|
t.datetime "banned_at"
|
||||||
@@ -57,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "material_id", null: false
|
||||||
|
t.string "profile", default: "legacy_drive", null: false
|
||||||
|
t.string "export_path", null: false
|
||||||
|
t.boolean "enabled", default: true, null: false
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id"
|
||||||
|
t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true
|
||||||
|
t.index ["material_id"], name: "index_material_export_items_on_material_id"
|
||||||
|
t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "match_kind", null: false
|
||||||
|
t.string "sha256"
|
||||||
|
t.string "external_path_pattern"
|
||||||
|
t.string "reason", null: false
|
||||||
|
t.text "note"
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "material_id", null: false
|
t.bigint "material_id", null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
@@ -68,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.string "tag_name"
|
||||||
|
t.string "tag_category"
|
||||||
|
t.json "export_paths_json"
|
||||||
|
t.bigint "file_blob_id"
|
||||||
|
t.string "file_filename"
|
||||||
|
t.string "file_content_type"
|
||||||
|
t.bigint "file_byte_size"
|
||||||
|
t.string "file_checksum"
|
||||||
|
t.string "file_sha256"
|
||||||
|
t.datetime "file_suppressed_at"
|
||||||
|
t.string "file_suppression_reason"
|
||||||
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
||||||
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
||||||
|
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
|
||||||
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
||||||
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
||||||
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
|
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
|
||||||
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
|
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
|
||||||
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
|
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
|
||||||
t.index ["url"], name: "index_material_versions_on_url"
|
t.index ["url"], name: "index_material_versions_on_url"
|
||||||
|
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
@@ -88,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
|
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
|
||||||
|
t.integer "version_no", default: 1, null: false
|
||||||
|
t.datetime "file_suppressed_at"
|
||||||
|
t.bigint "file_suppressed_by_user_id"
|
||||||
|
t.string "file_suppression_reason"
|
||||||
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
|
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
|
||||||
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
|
||||||
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
|
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
|
||||||
|
t.index ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id"
|
||||||
t.index ["parent_id"], name: "index_materials_on_parent_id"
|
t.index ["parent_id"], name: "index_materials_on_parent_id"
|
||||||
t.index ["tag_id"], name: "index_materials_on_tag_id"
|
t.index ["tag_id"], name: "index_materials_on_tag_id"
|
||||||
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
|
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
|
||||||
@@ -246,6 +364,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.string "event_type", null: false
|
t.string "event_type", null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "category", null: false
|
t.string "category", null: false
|
||||||
|
t.datetime "deprecated_at"
|
||||||
t.text "aliases", null: false
|
t.text "aliases", null: false
|
||||||
t.text "parent_tag_ids", null: false
|
t.text "parent_tag_ids", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -263,10 +382,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "post_count", default: 0, null: false
|
t.integer "post_count", default: 0, null: false
|
||||||
|
t.datetime "deprecated_at"
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
|
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
||||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||||
|
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -478,6 +600,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||||
|
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||||
|
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||||
|
add_foreign_key "gekanator_games", "users"
|
||||||
|
add_foreign_key "gekanator_question_examples", "gekanator_games"
|
||||||
|
add_foreign_key "gekanator_question_examples", "gekanator_questions"
|
||||||
|
add_foreign_key "gekanator_question_examples", "posts"
|
||||||
|
add_foreign_key "gekanator_question_examples", "users"
|
||||||
|
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||||
|
add_foreign_key "gekanator_question_suggestions", "users"
|
||||||
|
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
||||||
|
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
|
||||||
|
add_foreign_key "material_export_items", "materials"
|
||||||
|
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "tags"
|
add_foreign_key "material_versions", "tags"
|
||||||
@@ -486,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
add_foreign_key "materials", "materials", column: "parent_id"
|
add_foreign_key "materials", "materials", column: "parent_id"
|
||||||
add_foreign_key "materials", "tags"
|
add_foreign_key "materials", "tags"
|
||||||
add_foreign_key "materials", "users", column: "created_by_user_id"
|
add_foreign_key "materials", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "materials", "users", column: "file_suppressed_by_user_id"
|
||||||
add_foreign_key "materials", "users", column: "updated_by_user_id"
|
add_foreign_key "materials", "users", column: "updated_by_user_id"
|
||||||
add_foreign_key "nico_tag_relations", "tags"
|
add_foreign_key "nico_tag_relations", "tags"
|
||||||
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :post_similarity do
|
namespace :post_similarity do
|
||||||
desc '関聯投稿テーブル作成'
|
desc '関聯投稿テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Post, :tags)
|
Similarity::Calc.call(Post, :active_tags)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :tag_similarity do
|
namespace :tag_similarity do
|
||||||
desc '関聯タグ・テーブル作成'
|
desc '関聯タグ・テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Tag, :posts)
|
Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe MaterialExportItem, type: :model do
|
||||||
|
let(:user) { create(:user, :member) }
|
||||||
|
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
|
||||||
|
let(:material) do
|
||||||
|
Material.create!(tag:, url: 'https://example.com/material',
|
||||||
|
created_by_user: user, updated_by_user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects blank export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects absolute export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive',
|
||||||
|
export_path: '/素材/a.png')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects parent traversal export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive',
|
||||||
|
export_path: '素材/../a.png')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects double slash export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive',
|
||||||
|
export_path: '素材//a.png')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects dot segment export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive',
|
||||||
|
export_path: './素材/a.png')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects trailing slash export_path' do
|
||||||
|
item = described_class.new(material:, profile: 'legacy_drive',
|
||||||
|
export_path: '素材/a/')
|
||||||
|
|
||||||
|
expect(item).not_to be_valid
|
||||||
|
expect(item.errors[:export_path]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe TagNameSanitisationRule, type: :model do
|
RSpec.describe TagNameSanitisationRule, type: :model do
|
||||||
|
before do
|
||||||
|
described_class.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
describe '.sanitise' do
|
describe '.sanitise' do
|
||||||
before do
|
before do
|
||||||
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
||||||
|
|||||||
@@ -1,6 +1,79 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Tag, type: :model do
|
RSpec.describe Tag, type: :model do
|
||||||
|
describe '.normalise_tags!' do
|
||||||
|
it 'rejects deprecated tags when deny_deprecated is enabled' do
|
||||||
|
tag_name = TagName.create!(name: 'normalise deprecated tag')
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
tag_name:,
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: 1.day.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.normalise_tags!(
|
||||||
|
[deprecated_tag.name],
|
||||||
|
deny_deprecated: true
|
||||||
|
)
|
||||||
|
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
|
||||||
|
expect(error.tag_names).to eq([deprecated_tag.name])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.expand_parent_tags' do
|
||||||
|
it 'expands through multiple deprecated parents to an active ancestor' do
|
||||||
|
child = create(:tag, name: 'expand_child')
|
||||||
|
deprecated_parent = create(
|
||||||
|
:tag,
|
||||||
|
name: 'expand_deprecated_parent',
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = create(
|
||||||
|
:tag,
|
||||||
|
name: 'expand_deprecated_grandparent',
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_ancestor = create(:tag, name: 'expand_active_ancestor')
|
||||||
|
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
||||||
|
|
||||||
|
expanded = described_class.expand_parent_tags([child])
|
||||||
|
|
||||||
|
expect(expanded).to include(
|
||||||
|
child,
|
||||||
|
deprecated_parent,
|
||||||
|
deprecated_grandparent,
|
||||||
|
active_ancestor
|
||||||
|
)
|
||||||
|
expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'terminates when implications contain a cycle' do
|
||||||
|
first = create(:tag, name: 'expand_cycle_first')
|
||||||
|
second = create(:tag, name: 'expand_cycle_second')
|
||||||
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
|
|
||||||
|
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'deprecated validation' do
|
||||||
|
it 'rejects deprecated nico tags' do
|
||||||
|
tag = build(
|
||||||
|
:tag,
|
||||||
|
name: 'nico:deprecated_validation',
|
||||||
|
category: :nico,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(tag).not_to be_valid
|
||||||
|
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '.merge_tags!' do
|
describe '.merge_tags!' do
|
||||||
let!(:target_tag) { create(:tag, category: :general) }
|
let!(:target_tag) { create(:tag, category: :general) }
|
||||||
let!(:source_tag) { create(:tag, category: :general) }
|
let!(:source_tag) { create(:tag, category: :general) }
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Gekanator games API', type: :request do
|
||||||
|
let!(:admin) { create_admin_user! }
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
||||||
|
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
||||||
|
|
||||||
|
describe 'POST /gekanator/games' do
|
||||||
|
it 'stores a won game' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: guessed_post.id,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
game = GekanatorGame.find(json['id'])
|
||||||
|
expect(game.user).to eq(admin)
|
||||||
|
expect(game.guessed_post).to eq(guessed_post)
|
||||||
|
expect(game.correct_post).to eq(guessed_post)
|
||||||
|
expect(game.won).to eq(true)
|
||||||
|
expect(game.question_count).to eq(1)
|
||||||
|
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores a lost game with the correct post' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
question_count: 4,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
game = GekanatorGame.find(json['id'])
|
||||||
|
expect(game.correct_post).to eq(correct_post)
|
||||||
|
expect(game.won).to eq(false)
|
||||||
|
expect(game.question_count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a game without the correct post' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
question_count: 4,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unauthorized without a user' do
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: guessed_post.id,
|
||||||
|
answers: [] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores a game for a non-admin user' do
|
||||||
|
sign_in_as user
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: guessed_post.id,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(GekanatorGame.find(json['id']).user).to eq(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -0,0 +1,33 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
|
||||||
|
RSpec.describe 'Gekanator posts API', type: :request do
|
||||||
|
describe 'GET /gekanator/posts' do
|
||||||
|
it 'omits deprecated tags and returns the stored similarity cosine' do
|
||||||
|
active_tag = Tag.create!(name: 'active tag', category: :general)
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
|
||||||
|
target_post = Post.create!(title: 'target', url: 'https://example.com/target')
|
||||||
|
|
||||||
|
PostTag.create!(post: post_record, tag: active_tag)
|
||||||
|
PostTag.create!(post: post_record, tag: deprecated_tag)
|
||||||
|
PostTag.create!(post: target_post, tag: deprecated_tag)
|
||||||
|
PostSimilarity.create!(post: post_record, target_post:, cos: 0.375)
|
||||||
|
|
||||||
|
get '/gekanator/posts'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
post_json = json.fetch('posts').find { |post| post.fetch('id') == post_record.id }
|
||||||
|
expect(post_json.fetch('tags').map { |tag| tag.fetch('name') }).to eq(['active tag'])
|
||||||
|
expect(post_json.fetch('post_similarity_edges')).to contain_exactly(
|
||||||
|
'target_post_id' => target_post.id,
|
||||||
|
'cos' => 0.375
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Materials API', type: :request do
|
RSpec.describe 'Materials API', type: :request do
|
||||||
|
include ActiveJob::TestHelper
|
||||||
|
|
||||||
let!(:member_user) { create(:user, :member) }
|
let!(:member_user) { create(:user, :member) }
|
||||||
|
let!(:admin_user) { create(:user, :admin) }
|
||||||
let!(:guest_user) { create(:user) }
|
let!(:guest_user) { create(:user) }
|
||||||
|
|
||||||
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
||||||
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
|
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
|
||||||
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
|
Material.new(tag:, parent:, url:,
|
||||||
|
created_by_user: user,
|
||||||
|
updated_by_user: user).tap do |material|
|
||||||
material.file.attach(file) if file
|
material.file.attach(file) if file
|
||||||
material.save!
|
material.save!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /materials' do
|
describe 'GET /materials' do
|
||||||
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
|
let!(:tag_a) do
|
||||||
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
|
Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
|
||||||
|
end
|
||||||
|
let!(:tag_b) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
let!(:material_a) do
|
let!(:material_a) do
|
||||||
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
|
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:material_b) do
|
let!(:material_b) do
|
||||||
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
|
build_material(tag: tag_b, user: member_user, parent: material_a,
|
||||||
|
file: dummy_upload(filename: 'b.png'))
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
@@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /materials/:id' do
|
describe 'GET /materials/:id' do
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
|
let!(:tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
|
||||||
|
end
|
||||||
let!(:material) do
|
let!(:material) do
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
|
||||||
end
|
end
|
||||||
@@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when logged in' do
|
context 'when logged in but not member' do
|
||||||
before { sign_in_as(guest_user) }
|
before { sign_in_as(guest_user) }
|
||||||
|
|
||||||
|
it 'returns 403' do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_guest_forbidden',
|
||||||
|
file: dummy_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when member' do
|
||||||
|
before { sign_in_as(member_user) }
|
||||||
|
|
||||||
it 'returns 422 when tag is blank' do
|
it 'returns 422 when tag is blank' do
|
||||||
post '/materials', params: { tag: ' ', file: dummy_upload }
|
post '/materials', params: { tag: ' ', file: dummy_upload }
|
||||||
|
|
||||||
@@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect do
|
expect do
|
||||||
post '/materials', params: {
|
post '/materials', params: {
|
||||||
tag: 'material_create_new',
|
tag: 'material_create_new',
|
||||||
file: dummy_upload(filename: 'created.png')
|
file: dummy_upload(filename: 'created.png'),
|
||||||
|
export_paths: { legacy_drive: '伊地知ニジカ/created.png' }
|
||||||
}
|
}
|
||||||
end.to change(Material, :count).by(1)
|
end.to change(Material, :count).by(1)
|
||||||
.and change(Tag, :count).by(1)
|
.and change(Tag, :count).by(1)
|
||||||
.and change(TagName, :count).by(1)
|
.and change(TagName, :count).by(1)
|
||||||
|
.and change(MaterialVersion, :count).by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
material = Material.order(:id).last
|
material = Material.order(:id).last
|
||||||
expect(material.tag.name).to eq('material_create_new')
|
expect(material.tag.name).to eq('material_create_new')
|
||||||
expect(material.tag.category).to eq('material')
|
expect(material.tag.category).to eq('material')
|
||||||
expect(material.created_by_user).to eq(guest_user)
|
expect(material.created_by_user).to eq(member_user)
|
||||||
expect(material.updated_by_user).to eq(guest_user)
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
expect(material.file.attached?).to be(true)
|
expect(material.file.attached?).to be(true)
|
||||||
|
expect(material.version_no).to eq(1)
|
||||||
|
expect(material.material_versions.first.event_type).to eq('create')
|
||||||
|
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
|
||||||
|
expect(material.material_versions.first.export_paths_json).to eq(
|
||||||
|
'legacy_drive' => '伊地知ニジカ/created.png'
|
||||||
|
)
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
expect(json['id']).to eq(material.id)
|
||||||
expect(json.dig('tag', 'name')).to eq('material_create_new')
|
expect(json.dig('tag', 'name')).to eq('material_create_new')
|
||||||
expect(json['content_type']).to eq('image/png')
|
expect(json['content_type']).to eq('image/png')
|
||||||
|
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'snapshots attached file metadata and sha256' do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_file_version',
|
||||||
|
file: dummy_upload(filename: 'created.png', body: 'sha-body')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
version = Material.order(:id).last.material_versions.first
|
||||||
|
expect(version.file_blob_id).to be_present
|
||||||
|
expect(version.file_filename).to eq('created.png')
|
||||||
|
expect(version.file_content_type).to eq('image/png')
|
||||||
|
expect(version.file_byte_size).to eq('sha-body'.bytesize)
|
||||||
|
expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 when the existing tag is not material/character' do
|
it 'returns 422 when the existing tag is not material/character' do
|
||||||
@@ -219,11 +269,33 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect(json['url']).to eq('https://example.com/material-source')
|
expect(json['url']).to eq('https://example.com/material-source')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects sha256-blocked file upload' do
|
||||||
|
sha256 = Digest::SHA256.hexdigest('blocked-body')
|
||||||
|
MaterialImportBlock.create!(match_kind: 'sha256',
|
||||||
|
sha256:,
|
||||||
|
reason: 'copyright_high_risk',
|
||||||
|
created_by_user: admin_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_blocked_create',
|
||||||
|
file: dummy_upload(filename: 'blocked.png', body: 'blocked-body')
|
||||||
|
}
|
||||||
|
end.not_to change(Material, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'file' => ['抑止された素材です: copyright_high_risk']
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'PUT /materials/:id' do
|
describe 'PUT /materials/:id' do
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
|
let!(:tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
|
||||||
|
end
|
||||||
let!(:material) do
|
let!(:material) do
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
|
||||||
end
|
end
|
||||||
@@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
'tag' => ['タグは必須です.'])
|
'tag' => ['タグは必須です.'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 when both file and url are blank' do
|
it 'keeps the existing file when file and url are omitted' do
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
tag: 'material_update_no_payload'
|
tag: 'material_update_no_payload'
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('errors')).to include(
|
expect(material.reload.file.attached?).to be(true)
|
||||||
'file' => ['ファイルまたは URL は必須です.'],
|
|
||||||
'url' => ['ファイルまたは URL は必須です.'])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates tag, url, file, and updated_by_user' do
|
it 'updates tag, url, file, and updated_by_user' do
|
||||||
old_blob_id = material.file.blob.id
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
|
expect do
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
tag: 'material_update_new',
|
tag: 'material_update_new',
|
||||||
url: 'https://example.com/updated-source',
|
url: 'https://example.com/updated-source',
|
||||||
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
|
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'),
|
||||||
|
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
|
||||||
}
|
}
|
||||||
|
end.to change(MaterialVersion, :count).by(2)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(material.updated_by_user).to eq(member_user)
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
expect(material.file.attached?).to be(true)
|
expect(material.file.attached?).to be(true)
|
||||||
expect(material.file.blob.id).not_to eq(old_blob_id)
|
expect(material.file.blob.id).not_to eq(old_blob_id)
|
||||||
|
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
||||||
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
|
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
|
||||||
expect(material.file.blob.content_type).to eq('image/jpeg')
|
expect(material.file.blob.content_type).to eq('image/jpeg')
|
||||||
|
expect(material.version_no).to eq(2)
|
||||||
|
expect(material.material_versions.order(:version_no).last.event_type).to eq('update')
|
||||||
|
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg')
|
||||||
|
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
|
||||||
|
'legacy_drive' => '伊地知ニジカ/updated.jpg'
|
||||||
|
)
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
expect(json['id']).to eq(material.id)
|
||||||
expect(json['file']).to be_present
|
expect(json['file']).to be_present
|
||||||
@@ -315,7 +395,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'purges the existing file when file is omitted and url is provided' do
|
it 'detaches the existing file without purging blob when url replaces file' do
|
||||||
old_blob_id = material.file.blob.id
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
@@ -331,9 +411,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(material.updated_by_user).to eq(member_user)
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
expect(material.file.attached?).to be(false)
|
expect(material.file.attached?).to be(false)
|
||||||
|
|
||||||
expect(
|
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
||||||
ActiveStorage::Blob.where(id: old_blob_id).exists?
|
|
||||||
).to be(false)
|
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
expect(json['id']).to eq(material.id)
|
||||||
expect(json['file']).to be_nil
|
expect(json['file']).to be_nil
|
||||||
@@ -341,11 +419,190 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
|
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
|
||||||
expect(json['url']).to eq('https://example.com/updated-source')
|
expect(json['url']).to eq('https://example.com/updated-source')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not increase version for the same snapshot update' do
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: member_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_old'
|
||||||
|
}
|
||||||
|
end.not_to change(MaterialVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(material.reload.version_no).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'records update version when only export_path changes' do
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: member_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_old',
|
||||||
|
export_paths: { legacy_drive: '素材/only-path.png' }
|
||||||
|
}
|
||||||
|
end.to change(MaterialVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png')
|
||||||
|
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
|
||||||
|
'legacy_drive' => '素材/only-path.png'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes export_path item when blank is submitted' do
|
||||||
|
MaterialExportItem.create!(material:, profile: 'legacy_drive',
|
||||||
|
export_path: '素材/remove.png',
|
||||||
|
created_by_user: member_user)
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: member_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_old',
|
||||||
|
export_paths: { legacy_drive: '' }
|
||||||
|
}
|
||||||
|
end.to change(MaterialExportItem, :count).by(-1)
|
||||||
|
.and change(MaterialVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(material.reload.material_export_items).to be_empty
|
||||||
|
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects sha256-blocked replacement file' do
|
||||||
|
sha256 = Digest::SHA256.hexdigest('blocked-update')
|
||||||
|
MaterialImportBlock.create!(match_kind: 'sha256',
|
||||||
|
sha256:,
|
||||||
|
reason: 'source_owner_request',
|
||||||
|
created_by_user: admin_user)
|
||||||
|
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_old',
|
||||||
|
file: dummy_upload(filename: 'blocked.png', body: 'blocked-update')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(material.reload.file.blob.filename.to_s).to eq('old.png')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /materials/download.zip' do
|
||||||
|
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
|
||||||
|
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
|
||||||
|
let!(:material_a) do
|
||||||
|
build_material(tag: tag_a, user: member_user,
|
||||||
|
file: dummy_upload(filename: 'a.png', body: 'zip-a'))
|
||||||
|
end
|
||||||
|
let!(:material_b) do
|
||||||
|
build_material(tag: tag_b, user: member_user,
|
||||||
|
file: dummy_upload(filename: 'b.png', body: 'zip-b'))
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
MaterialExportItem.create!(material: material_a, profile: 'legacy_drive',
|
||||||
|
export_path: '素材/a.png',
|
||||||
|
created_by_user: member_user)
|
||||||
|
MaterialExportItem.create!(material: material_b, profile: 'legacy_drive',
|
||||||
|
export_path: '素材/b.png',
|
||||||
|
created_by_user: member_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses material_export_items.export_path as ZIP entry paths' do
|
||||||
|
get '/materials/download.zip', params: { profile: 'legacy_drive' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.media_type).to eq('application/zip')
|
||||||
|
expect(response.body.b).to include('素材/a.png'.b)
|
||||||
|
expect(response.body.b).to include('素材/b.png'.b)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters by tag_id' do
|
||||||
|
get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body.b).to include('素材/a.png'.b)
|
||||||
|
expect(response.body.b).not_to include('素材/b.png'.b)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not include suppressed materials' do
|
||||||
|
material_b.update!(file_suppressed_at: Time.current,
|
||||||
|
file_suppression_reason: 'copyright_high_risk')
|
||||||
|
|
||||||
|
get '/materials/download.zip', params: { profile: 'legacy_drive' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body.b).to include('素材/a.png'.b)
|
||||||
|
expect(response.body.b).not_to include('素材/b.png'.b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PATCH /materials/:id/suppress_file' do
|
||||||
|
let!(:tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material)
|
||||||
|
end
|
||||||
|
let!(:material) do
|
||||||
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows admin to suppress a file and records a suppress version' do
|
||||||
|
sign_in_as(admin_user)
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: member_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
patch "/materials/#{ material.id }/suppress_file",
|
||||||
|
params: { reason: 'copyright_high_risk' }
|
||||||
|
end.to change(MaterialVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
material.reload
|
||||||
|
expect(material.file_suppressed_at).to be_present
|
||||||
|
expect(material.file_suppressed_by_user).to eq(admin_user)
|
||||||
|
expect(material.file_suppression_reason).to eq('copyright_high_risk')
|
||||||
|
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress')
|
||||||
|
expect(json['file']).to be_nil
|
||||||
|
expect(json['file_suppressed_at']).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'purges blob when purge=true is requested' do
|
||||||
|
sign_in_as(admin_user)
|
||||||
|
old_blob_id = material.file.blob.id
|
||||||
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
|
created_by_user: member_user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
patch "/materials/#{ material.id }/suppress_file",
|
||||||
|
params: { reason: 'copyright_takedown', purge: '1' }
|
||||||
|
end.to have_enqueued_job(ActiveStorage::PurgeJob)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = material.material_versions.order(:version_no).last
|
||||||
|
expect(version.event_type).to eq('suppress')
|
||||||
|
expect(version.file_blob_id).to eq(old_blob_id)
|
||||||
|
expect(version.file_filename).to eq('suppress.png')
|
||||||
|
expect(version.file_sha256).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects member suppression' do
|
||||||
|
sign_in_as(member_user)
|
||||||
|
|
||||||
|
patch "/materials/#{ material.id }/suppress_file",
|
||||||
|
params: { reason: 'copyright_high_risk' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE /materials/:id' do
|
describe 'DELETE /materials/:id' do
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
|
let!(:tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
|
||||||
|
end
|
||||||
let!(:material) do
|
let!(:material) do
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -517,6 +517,24 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect([true, false]).to include(json['viewed'])
|
expect([true, false]).to include(json['viewed'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'omits deprecated tags' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated_post_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
PostTag.create!(post: post_record, tag: deprecated_tag)
|
||||||
|
|
||||||
|
request
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tag_names = json.fetch('tags').flat_map { |node|
|
||||||
|
[node.fetch('name')] + node.fetch('children').map { |child| child.fetch('name') }
|
||||||
|
}
|
||||||
|
expect(tag_names).to include('spec_tag')
|
||||||
|
expect(tag_names).not_to include('deprecated_post_tag')
|
||||||
|
end
|
||||||
|
|
||||||
context 'when post has parent, child, and sibling posts' do
|
context 'when post has parent, child, and sibling posts' do
|
||||||
let!(:parent_post) do
|
let!(:parent_post) do
|
||||||
create_parent_post!(
|
create_parent_post!(
|
||||||
@@ -697,6 +715,58 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).not_to include('manko')
|
expect(names).not_to include('manko')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects a deprecated tag specified directly' do
|
||||||
|
Tag.create!(
|
||||||
|
name: 'deprecated_direct_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'new post',
|
||||||
|
url: 'https://example.com/deprecated-direct-tag',
|
||||||
|
tags: 'deprecated_direct_tag',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'tags' => ['廃止済みタグは付与できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'expands through multiple deprecated parent tags and saves active ancestors' do
|
||||||
|
child = Tag.create!(name: 'active_child', category: :general)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'deprecated_parent',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = Tag.create!(
|
||||||
|
name: 'deprecated_grandparent',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
|
||||||
|
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'expanded post',
|
||||||
|
url: 'https://example.com/expanded-deprecated-parent',
|
||||||
|
tags: 'active_child',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
saved_names = Post.find(json.fetch('id')).tags.map(&:name)
|
||||||
|
expect(saved_names).to include('active_child', 'active_grandparent')
|
||||||
|
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
|
||||||
|
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!(
|
||||||
@@ -930,6 +1000,26 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).to include('spec_tag_2')
|
expect(names).to include('spec_tag_2')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects a deprecated tag specified directly' do
|
||||||
|
Tag.create!(
|
||||||
|
name: 'deprecated_update_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
put "/posts/#{ post_record.id }", params: post_update_params(
|
||||||
|
post_record,
|
||||||
|
title: 'updated title',
|
||||||
|
tags: 'deprecated_update_tag'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'tags' => ['廃止済みタグは付与できません.']
|
||||||
|
)
|
||||||
|
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!(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type:,
|
event_type:,
|
||||||
name:,
|
name:,
|
||||||
category:,
|
category:,
|
||||||
|
deprecated_at: nil,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
parent_tags: [],
|
parent_tags: [],
|
||||||
created_by_user:,
|
created_by_user:,
|
||||||
@@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
name: name,
|
name: name,
|
||||||
category: category,
|
category: category,
|
||||||
|
deprecated_at: deprecated_at,
|
||||||
aliases: Array(aliases).join(' '),
|
aliases: Array(aliases).join(' '),
|
||||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
created_by_user: created_by_user,
|
created_by_user: created_by_user,
|
||||||
@@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: 'update',
|
event_type: 'update',
|
||||||
name: 'new_tag_name',
|
name: 'new_tag_name',
|
||||||
category: 'meme',
|
category: 'meme',
|
||||||
|
deprecated_at: t_v2,
|
||||||
aliases: ['alias_shared', 'alias_new'],
|
aliases: ['alias_shared', 'alias_new'],
|
||||||
parent_tags: [parent_shared, parent_new],
|
parent_tags: [parent_shared, parent_new],
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
@@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'meme',
|
'current' => 'meme',
|
||||||
'prev' => 'general'
|
'prev' => 'general'
|
||||||
)
|
)
|
||||||
|
expect(latest.fetch('deprecated_at')).to eq(
|
||||||
|
'current' => t_v2.iso8601,
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
expect(latest.fetch('aliases')).to include(
|
expect(latest.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'context' },
|
{ 'name' => 'alias_shared', 'type' => 'context' },
|
||||||
{ 'name' => 'alias_new', 'type' => 'added' },
|
{ 'name' => 'alias_new', 'type' => 'added' },
|
||||||
@@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'general',
|
'current' => 'general',
|
||||||
'prev' => nil
|
'prev' => nil
|
||||||
)
|
)
|
||||||
|
expect(first.fetch('deprecated_at')).to eq(
|
||||||
|
'current' => nil,
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
expect(first.fetch('aliases')).to include(
|
expect(first.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'added' },
|
{ 'name' => 'alias_shared', 'type' => 'added' },
|
||||||
{ 'name' => 'alias_old', 'type' => 'added' }
|
{ 'name' => 'alias_old', 'type' => 'added' }
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -123,6 +124,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -149,6 +151,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'put_tag_alias_only_alias',
|
aliases: 'put_tag_alias_only_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(response_tags.first['id']).to eq(meme.id)
|
expect(response_tags.first['id']).to eq(meme.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'filters tags by deprecated state' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated_filter',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: 1.day.from_now
|
||||||
|
)
|
||||||
|
active_tag = Tag.create!(name: 'active_filter', category: :general)
|
||||||
|
|
||||||
|
get '/tags', params: { name: '_filter', deprecated: '1' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response_names).to include(deprecated_tag.name)
|
||||||
|
expect(response_names).not_to include(active_tag.name)
|
||||||
|
|
||||||
|
get '/tags', params: { name: '_filter', deprecated: '0' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response_names).to include(active_tag.name)
|
||||||
|
expect(response_names).not_to include(deprecated_tag.name)
|
||||||
|
end
|
||||||
|
|
||||||
it 'filters tags by post_count range' do
|
it 'filters tags by post_count range' do
|
||||||
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
||||||
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
||||||
@@ -301,6 +322,21 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(t['matched_alias']).to eq('unko')
|
expect(t['matched_alias']).to eq('unko')
|
||||||
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'omits deprecated tags' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'spec_deprecated',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_tag.update_columns(post_count: 1)
|
||||||
|
|
||||||
|
get '/tags/autocomplete', params: { q: 'spec_', present: '0' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include('spec_tag')
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include('spec_deprecated')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name' do
|
describe 'GET /tags/name/:name' do
|
||||||
@@ -437,6 +473,32 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'updates deprecated state and records it in tag versions' do
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: { deprecated: '1' }
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(tag.reload.deprecated_at).to be_present
|
||||||
|
|
||||||
|
versions = tag.tag_versions.order(:version_no)
|
||||||
|
expect(versions.first.deprecated_at).to be_nil
|
||||||
|
expect(versions.second.deprecated_at).to eq(tag.deprecated_at)
|
||||||
|
expect(json.fetch('deprecated_at')).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects deprecating a nico tag' do
|
||||||
|
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
|
||||||
|
|
||||||
|
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(nico_tag.reload.deprecated_at).to be_nil
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'deprecated' => ['ニコタグは廃止できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns 422 when changing normal tag category to nico' do
|
it 'returns 422 when changing normal tag category to nico' do
|
||||||
expect {
|
expect {
|
||||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||||
@@ -585,6 +647,111 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(row['has_children']).to eq(true)
|
expect(row['has_children']).to eq(true)
|
||||||
expect(row['children']).to eq([])
|
expect(row['children']).to eq([])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'passes through deprecated tags when finding children' do
|
||||||
|
deprecated_middle = Tag.create!(
|
||||||
|
name: 'depth_deprecated_middle',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
visible_descendant = Tag.create!(
|
||||||
|
name: 'depth_visible_descendant',
|
||||||
|
category: :material
|
||||||
|
)
|
||||||
|
TagImplication.create!(parent_tag: root_material, tag: deprecated_middle)
|
||||||
|
TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant'])
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'passes through multiple deprecated tags for roots and has_children' do
|
||||||
|
active_child = Tag.create!(
|
||||||
|
name: 'depth_active_child_below_deprecated',
|
||||||
|
category: :character
|
||||||
|
)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'depth_deprecated_parent',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = Tag.create!(
|
||||||
|
name: 'depth_deprecated_grandparent',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_ancestor = Tag.create!(
|
||||||
|
name: 'depth_active_ancestor',
|
||||||
|
category: :meme
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
||||||
|
|
||||||
|
get '/tags/with-depth'
|
||||||
|
|
||||||
|
root_names = json.map { |item| item.fetch('name') }
|
||||||
|
expect(root_names).to include('depth_active_ancestor')
|
||||||
|
expect(root_names).not_to include('depth_active_child_below_deprecated')
|
||||||
|
ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id }
|
||||||
|
expect(ancestor_json.fetch('has_children')).to eq(true)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: active_ancestor.id }
|
||||||
|
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include(
|
||||||
|
'depth_active_child_below_deprecated'
|
||||||
|
)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include(
|
||||||
|
'depth_deprecated_parent',
|
||||||
|
'depth_deprecated_grandparent'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'treats an active tag with only deprecated ancestors as a root' do
|
||||||
|
active_child = Tag.create!(
|
||||||
|
name: 'depth_root_below_deprecated',
|
||||||
|
category: :character
|
||||||
|
)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'depth_root_deprecated_parent',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
||||||
|
|
||||||
|
get '/tags/with-depth'
|
||||||
|
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include(
|
||||||
|
'depth_root_below_deprecated'
|
||||||
|
)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include(
|
||||||
|
'depth_root_deprecated_parent'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'terminates when deprecated implications contain a cycle' do
|
||||||
|
first = Tag.create!(
|
||||||
|
name: 'depth_cycle_first',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
second = Tag.create!(
|
||||||
|
name: 'depth_cycle_second',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: first, parent_tag: root_material)
|
||||||
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to eq([])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name/materials' do
|
describe 'GET /tags/name/:name/materials' do
|
||||||
@@ -732,6 +899,20 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(tag.category).to eq('general')
|
expect(tag.category).to eq('general')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'deprecated がなければ 422 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'new',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'deprecated' => ['廃止状態は必須です.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
||||||
old_parent = Tag.create!(
|
old_parent = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_old_parent'),
|
tag_name: TagName.create!(name: 'put_old_parent'),
|
||||||
@@ -749,6 +930,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'put_alias_a put_alias_b put_alias_a',
|
aliases: 'put_alias_a put_alias_b put_alias_a',
|
||||||
parent_tags: 'put_kept_parent put_new_parent',
|
parent_tags: 'put_kept_parent put_new_parent',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -793,6 +975,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'spec_tag put_alias_self_test',
|
aliases: 'spec_tag put_alias_self_test',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -810,6 +993,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: 'spec_tag',
|
parent_tags: 'spec_tag',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -825,6 +1009,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meta',
|
category: 'meta',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
@@ -860,6 +1045,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: new_parent.name,
|
parent_tags: new_parent.name,
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -875,6 +1061,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -896,6 +1083,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(NicoTagVersion, :count)
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
@@ -916,6 +1104,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: old_category,
|
category: old_category,
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -946,6 +1135,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -981,6 +1171,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko put_stolen_alias',
|
aliases: 'unko put_stolen_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change { tag.reload.tag_versions.count }.by(2)
|
.to change { tag.reload.tag_versions.count }.by(2)
|
||||||
@@ -1015,6 +1206,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: child.name,
|
parent_tags: child.name,
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
@@ -1036,6 +1228,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
let!(:tag) do
|
||||||
|
Tag.create!(
|
||||||
|
tag_name: tn,
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.zone.local(2026, 6, 1)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
describe 'GET /wiki' do
|
describe 'GET /wiki' do
|
||||||
it 'returns wiki pages with title' do
|
it 'returns wiki pages with title' do
|
||||||
@@ -30,6 +37,8 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json[0]).to have_key('title')
|
expect(json[0]).to have_key('title')
|
||||||
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
||||||
|
wiki_json = json.find { |item| item.fetch('id') == page.id }
|
||||||
|
expect(wiki_json.fetch('deprecated_at')).to eq(tag.deprecated_at.iso8601(3))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'id' => page.id,
|
'id' => page.id,
|
||||||
'title' => 'spec_wiki_title')
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
'kind' => 'content',
|
'kind' => 'content',
|
||||||
'message' => 'r2'
|
'message' => 'r2'
|
||||||
)
|
)
|
||||||
expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title')
|
expect(top['wiki_page']).to include(
|
||||||
|
'id' => page.id,
|
||||||
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3)
|
||||||
|
)
|
||||||
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
||||||
expect(top).to have_key('timestamp')
|
expect(top).to have_key('timestamp')
|
||||||
|
|
||||||
@@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'wiki_page_id' => page.id,
|
'wiki_page_id' => page.id,
|
||||||
'title' => 'spec_wiki_title',
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3),
|
||||||
'older_revision_id' => rev_a.id,
|
'older_revision_id' => rev_a.id,
|
||||||
'newer_revision_id' => rev_b.id
|
'newer_revision_id' => rev_b.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Gekanator::QuestionSuggestionAiConverter do
|
||||||
|
let(:user) { create(:user, :member) }
|
||||||
|
let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
||||||
|
let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
||||||
|
let(:game) do
|
||||||
|
GekanatorGame.create!(
|
||||||
|
user: user,
|
||||||
|
guessed_post: guessed_post,
|
||||||
|
correct_post: correct_post,
|
||||||
|
won: false,
|
||||||
|
question_count: 1,
|
||||||
|
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_suggestion!(question_text:, answer: 'yes')
|
||||||
|
GekanatorQuestionSuggestion.create!(
|
||||||
|
gekanator_game: game,
|
||||||
|
user: user,
|
||||||
|
question_text: question_text,
|
||||||
|
answer: answer
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts title-contains suggestions to pending ai-generated questions' do
|
||||||
|
suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.call(suggestion: suggestion, user: user)
|
||||||
|
}.to change { GekanatorQuestion.count }.by(1)
|
||||||
|
.and change { GekanatorAiRun.count }.by(1)
|
||||||
|
|
||||||
|
question = GekanatorQuestion.last
|
||||||
|
expect(question).to have_attributes(
|
||||||
|
text: '題名に「結束バンド」が含まれる?',
|
||||||
|
kind: 'title',
|
||||||
|
source: 'ai_generated',
|
||||||
|
status: 'pending',
|
||||||
|
priority_weight: 0.95,
|
||||||
|
gekanator_question_suggestion_id: suggestion.id,
|
||||||
|
created_by_id: user.id
|
||||||
|
)
|
||||||
|
expect(question.condition).to include(
|
||||||
|
'type' => 'title-contains',
|
||||||
|
'text' => '結束バンド'
|
||||||
|
)
|
||||||
|
expect(GekanatorAiRun.last).to have_attributes(
|
||||||
|
gekanator_question_suggestion_id: suggestion.id,
|
||||||
|
model: 'heuristic_converter_v1',
|
||||||
|
status: 'succeeded'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts concrete non-unknown suggestions to post-similarity questions' do
|
||||||
|
suggestion = create_suggestion!(
|
||||||
|
question_text: '喜多ちゃんが泣いてる?',
|
||||||
|
answer: 'partial'
|
||||||
|
)
|
||||||
|
|
||||||
|
question = described_class.call(suggestion: suggestion, user: user)
|
||||||
|
|
||||||
|
expect(question).to have_attributes(
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'ai_generated',
|
||||||
|
status: 'pending',
|
||||||
|
priority_weight: 1.0
|
||||||
|
)
|
||||||
|
expect(question.condition).to include(
|
||||||
|
'type' => 'post-similarity',
|
||||||
|
'postId' => correct_post.id,
|
||||||
|
'answer' => 'partial',
|
||||||
|
'threshold' => 0.65
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'records a failed run when the suggestion cannot be converted' do
|
||||||
|
suggestion = create_suggestion!(
|
||||||
|
question_text: 'よく分からない質問?',
|
||||||
|
answer: 'unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
expect(described_class.call(suggestion: suggestion, user: user)).to be_nil
|
||||||
|
}.not_to change { GekanatorQuestion.count }
|
||||||
|
|
||||||
|
expect(GekanatorAiRun.last).to have_attributes(
|
||||||
|
gekanator_question_suggestion_id: suggestion.id,
|
||||||
|
status: 'failed'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns an existing generated question without creating a duplicate run' do
|
||||||
|
suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?')
|
||||||
|
existing = GekanatorQuestion.create!(
|
||||||
|
text: 'タイトルは 10 文字以上?',
|
||||||
|
kind: 'title',
|
||||||
|
source: 'ai_generated',
|
||||||
|
status: 'pending',
|
||||||
|
priority_weight: 0.95,
|
||||||
|
condition: { type: 'title-length-at-least', length: 10 },
|
||||||
|
gekanator_question_suggestion: suggestion,
|
||||||
|
created_by: user
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing)
|
||||||
|
}.not_to change { GekanatorAiRun.count }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,11 +4,12 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'post_similarity:calc' do
|
RSpec.describe 'post_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calls Similarity::Calc with Post and :tags' do
|
it 'calculates similarities from active tags only' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
|
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -22,6 +23,8 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
|
PostTag.create!(post: p1, tag: deprecated_tag)
|
||||||
|
PostTag.create!(post: p2, tag: deprecated_tag)
|
||||||
|
|
||||||
expect { run_rake_task("post_similarity:calc") }
|
expect { run_rake_task("post_similarity:calc") }
|
||||||
.to change { PostSimilarity.count }.from(0)
|
.to change { PostSimilarity.count }.from(0)
|
||||||
@@ -29,6 +32,6 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
||||||
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
|
expect(ps.cos).to be_within(0.0001).of(0.5)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'tag_similarity:calc' do
|
RSpec.describe 'tag_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calls Similarity::Calc with Tag and :posts' do
|
it 'calculates similarities for active tags only' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
|
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -22,6 +23,7 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
|
PostTag.create!(post: p1, tag: deprecated_tag)
|
||||||
|
|
||||||
expect { run_rake_task("tag_similarity:calc") }
|
expect { run_rake_task("tag_similarity:calc") }
|
||||||
.to change { TagSimilarity.count }.from(0)
|
.to change { TagSimilarity.count }.from(0)
|
||||||
@@ -29,6 +31,7 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
||||||
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
|
expect(TagSimilarity.where(tag_id: deprecated_tag.id)).to be_empty
|
||||||
|
expect(TagSimilarity.where(target_tag_id: deprecated_tag.id)).to be_empty
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ npm run lint
|
|||||||
|
|
||||||
If either command cannot be run or fails, report the exact command and failure.
|
If either command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
|
Do not create, modify, or run tests unless the user explicitly asks for test
|
||||||
|
work. When the user asks for tests, keep working and rerun them until they
|
||||||
|
pass or the remaining failure is clearly blocked.
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|
||||||
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
||||||
|
|||||||
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 559 KiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 146 KiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 1.2 MiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 188 KiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 201 KiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 196 KiB |
バイナリファイルは表示されません.
|
変更後 幅: | 高さ: | サイズ: 179 KiB |
+3
-1
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||||
import MorePage from '@/pages/MorePage'
|
import MorePage from '@/pages/MorePage'
|
||||||
|
import GekanatorPage from '@/pages/GekanatorPage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
@@ -68,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||||
<Route index element={<MaterialListPage/>}/>
|
<Route index element={<MaterialListPage/>}/>
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
<Route path="new" element={<MaterialNewPage/>}/>
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
<Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
@@ -80,6 +81,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
<Route path="/tos" element={<TOSPage/>}/>
|
||||||
|
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
<Route path="/more" element={<MorePage/>}/>
|
||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { act, fireEvent, render } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createRef } from 'react'
|
||||||
|
|
||||||
|
import NicoViewer from '@/components/NicoViewer'
|
||||||
|
|
||||||
|
import type { NiconicoViewerHandle } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
describe ('NicoViewer', () => {
|
||||||
|
afterEach (() => {
|
||||||
|
vi.useRealTimers ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('does not time out after metadata reports a playable duration', () => {
|
||||||
|
vi.useFakeTimers ()
|
||||||
|
|
||||||
|
const onError = vi.fn ()
|
||||||
|
const onMetadataChange = vi.fn ()
|
||||||
|
const { container } = render (
|
||||||
|
<NicoViewer
|
||||||
|
id="sm12345"
|
||||||
|
width={640}
|
||||||
|
height={360}
|
||||||
|
onMetadataChange={onMetadataChange}
|
||||||
|
onError={onError}/>,
|
||||||
|
)
|
||||||
|
const iframe = container.querySelector ('iframe')
|
||||||
|
expect (iframe).not.toBeNull ()
|
||||||
|
|
||||||
|
fireEvent.load (iframe!)
|
||||||
|
act (() => {
|
||||||
|
window.dispatchEvent (new MessageEvent ('message', {
|
||||||
|
origin: 'https://embed.nicovideo.jp',
|
||||||
|
source: iframe!.contentWindow,
|
||||||
|
data: {
|
||||||
|
eventName: 'playerMetadataChange',
|
||||||
|
data: {
|
||||||
|
currentTime: 7,
|
||||||
|
duration: 120,
|
||||||
|
isVideoMetaDataLoaded: true,
|
||||||
|
maximumBuffered: 30,
|
||||||
|
muted: false,
|
||||||
|
showComment: true,
|
||||||
|
volume: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
act (() => {
|
||||||
|
vi.advanceTimersByTime (8_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (onMetadataChange).toHaveBeenCalled ()
|
||||||
|
expect (onError).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('seeks with milliseconds', () => {
|
||||||
|
const ref = createRef<NiconicoViewerHandle> ()
|
||||||
|
const { container } = render (
|
||||||
|
<NicoViewer
|
||||||
|
ref={ref}
|
||||||
|
id="sm12345"
|
||||||
|
width={640}
|
||||||
|
height={360}/>,
|
||||||
|
)
|
||||||
|
const iframe = container.querySelector ('iframe')!
|
||||||
|
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
|
||||||
|
|
||||||
|
act (() => {
|
||||||
|
ref.current!.seek (7_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (postMessage).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({
|
||||||
|
eventName: 'seek',
|
||||||
|
data: { time: 7_000 },
|
||||||
|
}),
|
||||||
|
'https://embed.nicovideo.jp',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -129,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
|||||||
}, [playerId, postToPlayer])
|
}, [playerId, postToPlayer])
|
||||||
|
|
||||||
const seek = useCallback ((time: number) => {
|
const seek = useCallback ((time: number) => {
|
||||||
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
|
postToPlayer (
|
||||||
|
{ eventName: 'seek', sourceConnectorType: 1, playerId,
|
||||||
|
data: { time } })
|
||||||
}, [playerId, postToPlayer])
|
}, [playerId, postToPlayer])
|
||||||
|
|
||||||
const mute = useCallback (() => {
|
const mute = useCallback (() => {
|
||||||
@@ -202,6 +204,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
|||||||
|
|
||||||
if (data.eventName === 'playerMetadataChange')
|
if (data.eventName === 'playerMetadataChange')
|
||||||
{
|
{
|
||||||
|
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
|
||||||
|
clearLoadCompleteTimer ()
|
||||||
|
|
||||||
onMetadataChange?.(data.data)
|
onMetadataChange?.(data.data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
|
|||||||
confirm: vi.fn (),
|
confirm: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const nicoViewer = vi.hoisted (() => ({
|
||||||
|
props: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||||
useDialogue: () => dialogue,
|
useDialogue: () => dialogue,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock ('@/components/NicoViewer', () => ({
|
vi.mock ('@/components/NicoViewer', () => ({
|
||||||
default: ({ id }: { id: string }) => <div>Nico:{id}</div>,
|
default: (props: { id: string }) => {
|
||||||
|
nicoViewer.props (props)
|
||||||
|
return <div>Nico:{props.id}</div>
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock ('react-youtube', () => ({
|
vi.mock ('react-youtube', () => ({
|
||||||
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
|
|||||||
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
|
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('reports niconico metadata as milliseconds', () => {
|
||||||
|
const onVideoReady = vi.fn ()
|
||||||
|
const onPlaybackChange = vi.fn ()
|
||||||
|
render (
|
||||||
|
<PostEmbed
|
||||||
|
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
|
||||||
|
onVideoReady={onVideoReady}
|
||||||
|
onPlaybackChange={onPlaybackChange}/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
|
||||||
|
currentTime: 7_000,
|
||||||
|
duration: 120_000,
|
||||||
|
isVideoMetaDataLoaded: true,
|
||||||
|
maximumBuffered: 30,
|
||||||
|
muted: false,
|
||||||
|
showComment: true,
|
||||||
|
volume: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (onVideoReady).toHaveBeenCalledWith (120_000)
|
||||||
|
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reports niconico video readiness only once', () => {
|
||||||
|
const onVideoReady = vi.fn ()
|
||||||
|
render (
|
||||||
|
<PostEmbed
|
||||||
|
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
|
||||||
|
onVideoReady={onVideoReady}/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
|
||||||
|
title: '動画',
|
||||||
|
videoId: 'sm12345',
|
||||||
|
lengthInSeconds: 120,
|
||||||
|
thumbnailUrl: 'https://example.com/thumb.jpg',
|
||||||
|
description: '',
|
||||||
|
viewCount: 1,
|
||||||
|
commentCount: 2,
|
||||||
|
mylistCount: 3,
|
||||||
|
postedAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
watchId: 12345,
|
||||||
|
})
|
||||||
|
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
|
||||||
|
currentTime: 7_000,
|
||||||
|
duration: 120_000,
|
||||||
|
isVideoMetaDataLoaded: true,
|
||||||
|
maximumBuffered: 30,
|
||||||
|
muted: false,
|
||||||
|
showComment: true,
|
||||||
|
volume: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (onVideoReady).toHaveBeenCalledTimes (1)
|
||||||
|
expect (onVideoReady).toHaveBeenCalledWith (120_000)
|
||||||
|
})
|
||||||
|
|
||||||
it ('embeds x/twitter status URLs', () => {
|
it ('embeds x/twitter status URLs', () => {
|
||||||
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
|
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import YoutubeEmbed from 'react-youtube'
|
import YoutubeEmbed from 'react-youtube'
|
||||||
|
|
||||||
import NicoViewer from '@/components/NicoViewer'
|
import NicoViewer from '@/components/NicoViewer'
|
||||||
@@ -36,6 +36,17 @@ const PostEmbed: FC<Props> = ({
|
|||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const [framed, setFramed] = useState (false)
|
const [framed, setFramed] = useState (false)
|
||||||
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
||||||
|
const niconicoVideoReadyRef = useRef (false)
|
||||||
|
|
||||||
|
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
|
||||||
|
if (niconicoVideoReadyRef.current
|
||||||
|
|| !(Number.isFinite (durationMs))
|
||||||
|
|| durationMs <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
niconicoVideoReadyRef.current = true
|
||||||
|
onVideoReady?.(durationMs)
|
||||||
|
}, [onVideoReady])
|
||||||
|
|
||||||
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
|
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
|
||||||
const currentTime = await player.getCurrentTime ()
|
const currentTime = await player.getCurrentTime ()
|
||||||
@@ -80,15 +91,20 @@ const PostEmbed: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
|
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
|
||||||
onVideoReady?.(info.lengthInSeconds * 1_000)
|
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
|
||||||
onLoadComplete?.(info)
|
onLoadComplete?.(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
|
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
|
||||||
|
notifyNiconicoVideoReady (meta.duration)
|
||||||
onPlaybackChange?.(meta.currentTime)
|
onPlaybackChange?.(meta.currentTime)
|
||||||
onMetadataChange?.(meta)
|
onMetadataChange?.(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
niconicoVideoReadyRef.current = false
|
||||||
|
}, [post.url])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(youtubePlayer) || !(onPlaybackChange))
|
if (!(youtubePlayer) || !(onPlaybackChange))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ describe ('TagLink', () => {
|
|||||||
expect (screen.getByText ('4')).toBeInTheDocument ()
|
expect (screen.getByText ('4')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('does not append deprecated state to the rendered tag name', () => {
|
||||||
|
renderWithProviders (
|
||||||
|
<TagLink
|
||||||
|
tag={buildTag ({
|
||||||
|
name: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
})}
|
||||||
|
withWiki={false}
|
||||||
|
withCount={false}/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
it ('links wiki markers to the correct detail route', () => {
|
it ('links wiki markers to the correct detail route', () => {
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
|||||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||||
{ name: '上映会 (β)', to: '/theatres/1' }] },
|
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||||
|
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||||
{ name: '一覧', to: '/users', visible: false },
|
{ name: '一覧', to: '/users', visible: false },
|
||||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { apiGet, apiPost } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
buildGekanatorQuestions,
|
||||||
|
expectedAnswerForQuestion,
|
||||||
|
fetchGekanatorPosts,
|
||||||
|
fetchGekanatorQuestions,
|
||||||
|
learnedSemanticSideForPost,
|
||||||
|
questionIdForCondition,
|
||||||
|
restoreGekanatorQuestion,
|
||||||
|
saveGekanatorExtraQuestionAnswers,
|
||||||
|
saveGekanatorGame,
|
||||||
|
saveGekanatorQuestionSuggestion,
|
||||||
|
} from '@/lib/gekanator'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GekanatorAnswerLog,
|
||||||
|
StoredGekanatorQuestion,
|
||||||
|
} from '@/lib/gekanator'
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedApiPost = vi.mocked(apiPost)
|
||||||
|
const mockedApiGet = vi.mocked(apiGet)
|
||||||
|
|
||||||
|
const post = (overrides: Partial<Post> = {}): Post => ({
|
||||||
|
id: 1,
|
||||||
|
versionNo: 1,
|
||||||
|
url: 'https://example.com/posts/1',
|
||||||
|
title: 'post title',
|
||||||
|
thumbnail: null,
|
||||||
|
thumbnailBase: null,
|
||||||
|
tags: [],
|
||||||
|
viewed: false,
|
||||||
|
related: [],
|
||||||
|
originalCreatedFrom: null,
|
||||||
|
originalCreatedBefore: null,
|
||||||
|
createdAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
uploadedUser: null,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Gekanator API functions', () => {
|
||||||
|
it('returns posts from the Gekanator posts endpoint', async () => {
|
||||||
|
const posts = [post()]
|
||||||
|
mockedApiGet.mockResolvedValueOnce({ posts })
|
||||||
|
|
||||||
|
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
|
||||||
|
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns questions from the Gekanator questions endpoint', async () => {
|
||||||
|
const questions: StoredGekanatorQuestion[] = []
|
||||||
|
mockedApiGet.mockResolvedValueOnce({ questions })
|
||||||
|
|
||||||
|
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
|
||||||
|
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('expectedAnswerForQuestion', () => {
|
||||||
|
it('returns a direct example answer when present', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 999,
|
||||||
|
answer: 'yes',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
exampleAnswers: {
|
||||||
|
1: 'partial',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the condition answer for the original post_similarity post', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 123,
|
||||||
|
answer: 'probably_no',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for an unrelated post_similarity post without examples', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 123,
|
||||||
|
answer: 'yes',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns yes for a matching tag question', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'tag:character:喜多郁代',
|
||||||
|
text: '喜多ちゃんが関係してる?',
|
||||||
|
kind: 'tag',
|
||||||
|
condition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: 'character:喜多郁代',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
expectedAnswerForQuestion(
|
||||||
|
question,
|
||||||
|
post({
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '喜多郁代',
|
||||||
|
category: 'character',
|
||||||
|
aliases: [],
|
||||||
|
parents: [],
|
||||||
|
postCount: 1,
|
||||||
|
createdAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
deprecatedAt: null,
|
||||||
|
hasWiki: false,
|
||||||
|
hasDeerjikists: false,
|
||||||
|
materialId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('yes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no for a non-matching tag question', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'tag:character:喜多郁代',
|
||||||
|
text: '喜多ちゃんが関係してる?',
|
||||||
|
kind: 'tag',
|
||||||
|
condition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: 'character:喜多郁代',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores example answers for direct title facts', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'title:length-at-least:20',
|
||||||
|
text: 'タイトルは 20 文字以上?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: {
|
||||||
|
type: 'title-length-at-least',
|
||||||
|
length: 20,
|
||||||
|
},
|
||||||
|
exampleAnswers: {
|
||||||
|
1: 'yes',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns yes for matching title-contains questions', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'title:contains:結束バンド',
|
||||||
|
text: '題名に「結束バンド」が含まれる?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: {
|
||||||
|
type: 'title-contains',
|
||||||
|
text: '結束バンド',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(expectedAnswerForQuestion(
|
||||||
|
question,
|
||||||
|
post({ title: '結束バンドのライブ' }),
|
||||||
|
)).toBe('yes')
|
||||||
|
expect(expectedAnswerForQuestion(
|
||||||
|
question,
|
||||||
|
post({ title: '後藤ひとりの休日' }),
|
||||||
|
)).toBe('no')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('learnedSemanticSideForPost', () => {
|
||||||
|
it('classifies post_similarity examples as positive, negative, or unknown', () => {
|
||||||
|
const question: StoredGekanatorQuestion = {
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 123,
|
||||||
|
answer: 'partial',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
exampleAnswers: {
|
||||||
|
1: 'yes',
|
||||||
|
2: 'probably_no',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
|
||||||
|
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
|
||||||
|
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
|
||||||
|
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('restoreGekanatorQuestion', () => {
|
||||||
|
it('uses default source and priority weight when omitted', () => {
|
||||||
|
const question = restoreGekanatorQuestion({
|
||||||
|
id: 'tag:character:喜多郁代',
|
||||||
|
text: '喜多ちゃんが関係してる?',
|
||||||
|
kind: 'tag',
|
||||||
|
condition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: 'character:喜多郁代',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(question.source).toBe('default')
|
||||||
|
expect(question.priorityWeight).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tests a post_similarity question using direct examples', () => {
|
||||||
|
const question = restoreGekanatorQuestion({
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 999,
|
||||||
|
answer: 'yes',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
exampleAnswers: {
|
||||||
|
1: 'yes',
|
||||||
|
2: 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(question.test(post({ id: 1 }))).toBe(true)
|
||||||
|
expect(question.test(post({ id: 2 }))).toBe(false)
|
||||||
|
expect(question.test(post({ id: 3 }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tests a post_similarity question against its configured partial answer', () => {
|
||||||
|
const question = restoreGekanatorQuestion({
|
||||||
|
id: 'post-similarity:10',
|
||||||
|
text: '喜多ちゃんが泣いてる?',
|
||||||
|
kind: 'post_similarity',
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1.2,
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 999,
|
||||||
|
answer: 'partial',
|
||||||
|
threshold: 0.65,
|
||||||
|
},
|
||||||
|
exampleAnswers: {
|
||||||
|
1: 'partial',
|
||||||
|
2: 'yes',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(question.test(post({ id: 1 }))).toBe(true)
|
||||||
|
expect(question.test(post({ id: 2 }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes legacy title-length-greater-than questions', () => {
|
||||||
|
const question = restoreGekanatorQuestion({
|
||||||
|
id: 'title:length-greater-than:20',
|
||||||
|
text: '題名が長めの投稿?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: {
|
||||||
|
type: 'title-length-greater-than',
|
||||||
|
length: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(question.id).toBe('title:length-at-least:21')
|
||||||
|
expect(question.condition).toEqual({
|
||||||
|
type: 'title-length-at-least',
|
||||||
|
length: 21,
|
||||||
|
})
|
||||||
|
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
|
||||||
|
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores title-contains questions with a title matcher', () => {
|
||||||
|
const question = restoreGekanatorQuestion({
|
||||||
|
id: 'title:contains:結束バンド',
|
||||||
|
text: '題名に「結束バンド」が含まれる?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: {
|
||||||
|
type: 'title-contains',
|
||||||
|
text: '結束バンド',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true)
|
||||||
|
expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildGekanatorQuestions', () => {
|
||||||
|
it('builds quantitative title length questions', () => {
|
||||||
|
const questions = buildGekanatorQuestions([
|
||||||
|
post({ id: 1, title: 'a' }),
|
||||||
|
post({ id: 2, title: 'bb' }),
|
||||||
|
post({ id: 3, title: 'ccc' }),
|
||||||
|
post({ id: 4, title: 'dddd' }),
|
||||||
|
])
|
||||||
|
const titleQuestion = questions.find(question =>
|
||||||
|
question.condition.type === 'title-length-at-least')
|
||||||
|
|
||||||
|
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
|
||||||
|
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds title-contains questions from repeated title words', () => {
|
||||||
|
const questions = buildGekanatorQuestions([
|
||||||
|
post({ id: 1, title: '結束バンド ライブ' }),
|
||||||
|
post({ id: 2, title: '結束バンド 新曲' }),
|
||||||
|
post({ id: 3, title: '後藤ひとり 練習' }),
|
||||||
|
post({ id: 4, title: '伊地知虹夏 練習' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const titleContainsQuestion = questions.find(question =>
|
||||||
|
question.condition.type === 'title-contains'
|
||||||
|
&& question.condition.text === '結束バンド')
|
||||||
|
|
||||||
|
expect(titleContainsQuestion).toMatchObject({
|
||||||
|
id: 'title:contains:結束バンド',
|
||||||
|
text: '題名に「結束バンド」が含まれる?',
|
||||||
|
kind: 'title',
|
||||||
|
source: 'default',
|
||||||
|
priorityWeight: .96,
|
||||||
|
})
|
||||||
|
expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true)
|
||||||
|
expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors question caps and title-contains toggles', () => {
|
||||||
|
const posts = [
|
||||||
|
post({ id: 1, title: '結束バンド ライブ' }),
|
||||||
|
post({ id: 2, title: '結束バンド 新曲' }),
|
||||||
|
post({ id: 3, title: '後藤ひとり 練習' }),
|
||||||
|
post({ id: 4, title: '伊地知虹夏 練習' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const capped = buildGekanatorQuestions(posts, {
|
||||||
|
titleContainsCap: 1,
|
||||||
|
totalQuestionCap: 1,
|
||||||
|
})
|
||||||
|
const withoutTitleContains = buildGekanatorQuestions(posts, {
|
||||||
|
includeTitleContains: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capped).toHaveLength(1)
|
||||||
|
expect(withoutTitleContains.some(question =>
|
||||||
|
question.condition.type === 'title-contains')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('questionIdForCondition', () => {
|
||||||
|
it('builds stable ids for title-contains questions', () => {
|
||||||
|
expect(questionIdForCondition({
|
||||||
|
type: 'title-contains',
|
||||||
|
text: '結束バンド',
|
||||||
|
})).toBe('title:contains:結束バンド')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Gekanator API writers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApiPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends game results using snake_case request keys', async () => {
|
||||||
|
mockedApiPost.mockResolvedValue({ id: 100 })
|
||||||
|
|
||||||
|
const answers: GekanatorAnswerLog[] = [
|
||||||
|
{
|
||||||
|
questionId: 'tag:character:喜多郁代',
|
||||||
|
questionText: '喜多ちゃんが関係してる?',
|
||||||
|
questionCondition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: 'character:喜多郁代',
|
||||||
|
},
|
||||||
|
questionMode: 'normal',
|
||||||
|
questionPurpose: 'effective_user_suggested',
|
||||||
|
effectiveQuestion: true,
|
||||||
|
learningQuestion: false,
|
||||||
|
answer: 'yes',
|
||||||
|
originalAnswer: 'partial',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveGekanatorGame({
|
||||||
|
guessedPostId: 1,
|
||||||
|
correctPostId: 2,
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ id: 100 })
|
||||||
|
|
||||||
|
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', {
|
||||||
|
guessed_post_id: 1,
|
||||||
|
correct_post_id: 2,
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
question_id: 'tag:character:喜多郁代',
|
||||||
|
question_text: '喜多ちゃんが関係してる?',
|
||||||
|
question_condition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: 'character:喜多郁代',
|
||||||
|
},
|
||||||
|
question_mode: 'normal',
|
||||||
|
question_purpose: 'effective_user_suggested',
|
||||||
|
effective_question: true,
|
||||||
|
learning_question: false,
|
||||||
|
answer: 'yes',
|
||||||
|
original_answer: 'partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends question suggestions using snake_case request keys', async () => {
|
||||||
|
mockedApiPost.mockResolvedValue({
|
||||||
|
id: 10,
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveGekanatorQuestionSuggestion({
|
||||||
|
gekanatorGameId: 100,
|
||||||
|
questionText: '喜多ちゃんが泣いてる?',
|
||||||
|
answer: 'yes',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: 10,
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', {
|
||||||
|
gekanator_game_id: 100,
|
||||||
|
question_text: '喜多ちゃんが泣いてる?',
|
||||||
|
answer: 'yes',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends extra question answers using snake_case request keys', async () => {
|
||||||
|
mockedApiPost.mockResolvedValue({
|
||||||
|
count: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
await saveGekanatorExtraQuestionAnswers({
|
||||||
|
gameId: 100,
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
questionId: 10,
|
||||||
|
answer: 'yes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: 11,
|
||||||
|
answer: 'probably_no',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockedApiPost).toHaveBeenCalledWith(
|
||||||
|
'/gekanator/games/100/extra_question_answers',
|
||||||
|
{
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
question_id: 10,
|
||||||
|
answer: 'yes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question_id: 11,
|
||||||
|
answer: 'probably_no',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import { apiGet, apiPost } from '@/lib/api'
|
||||||
|
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
export type GekanatorAnswerValue =
|
||||||
|
| 'yes'
|
||||||
|
| 'no'
|
||||||
|
| 'partial'
|
||||||
|
| 'probably_no'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type LearnedSemanticSide =
|
||||||
|
| 'positive'
|
||||||
|
| 'negative'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type GekanatorQuestionPurpose =
|
||||||
|
| 'effective_user_suggested'
|
||||||
|
| 'learning_user_suggested'
|
||||||
|
| 'normal'
|
||||||
|
|
||||||
|
export type GekanatorAnswerLog = {
|
||||||
|
questionId: string
|
||||||
|
questionText: string
|
||||||
|
questionCondition?: GekanatorQuestionCondition
|
||||||
|
questionMode?: 'normal' | 'winning_run'
|
||||||
|
questionPurpose?: GekanatorQuestionPurpose
|
||||||
|
effectiveQuestion?: boolean
|
||||||
|
learningQuestion?: boolean
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
|
originalAnswer: GekanatorAnswerValue }
|
||||||
|
|
||||||
|
export type GekanatorQuestionKind =
|
||||||
|
| 'tag'
|
||||||
|
| 'source'
|
||||||
|
| 'title'
|
||||||
|
| 'original_date'
|
||||||
|
| 'post_similarity'
|
||||||
|
|
||||||
|
export type GekanatorQuestionSource =
|
||||||
|
| 'default'
|
||||||
|
| 'user_suggested'
|
||||||
|
| 'ai_generated'
|
||||||
|
| 'admin_curated'
|
||||||
|
|
||||||
|
export type GekanatorPerformanceMode = 'normal'
|
||||||
|
|
||||||
|
export type GekanatorQuestionCondition =
|
||||||
|
| { type: 'tag'; key: string }
|
||||||
|
| { type: 'source'; host: string }
|
||||||
|
| { type: 'original-year'; year: number }
|
||||||
|
| { type: 'original-month'; month: number }
|
||||||
|
| { type: 'original-month-day'; monthDay: string }
|
||||||
|
| { type: 'title-length-at-least'; length: number }
|
||||||
|
| { type: 'title-length-greater-than'; length: number }
|
||||||
|
| { type: 'title-has-ascii' }
|
||||||
|
| { type: 'title-contains'; text: string }
|
||||||
|
| {
|
||||||
|
type: 'post-similarity'
|
||||||
|
postId: number
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
|
threshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type NonPostSimilarityCondition = Exclude<
|
||||||
|
GekanatorQuestionCondition,
|
||||||
|
{ type: 'post-similarity' }
|
||||||
|
>
|
||||||
|
|
||||||
|
export type GekanatorExtraQuestion = {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
source: GekanatorQuestionSource
|
||||||
|
priorityWeight: number }
|
||||||
|
|
||||||
|
export type StoredGekanatorQuestion = {
|
||||||
|
recordId?: number
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
kind: GekanatorQuestionKind
|
||||||
|
condition: GekanatorQuestionCondition
|
||||||
|
source?: GekanatorQuestionSource
|
||||||
|
priorityWeight?: number
|
||||||
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
|
||||||
|
|
||||||
|
export type GekanatorQuestion = {
|
||||||
|
recordId?: number
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
kind: GekanatorQuestionKind
|
||||||
|
condition: GekanatorQuestionCondition
|
||||||
|
source: GekanatorQuestionSource
|
||||||
|
priorityWeight: number
|
||||||
|
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
|
||||||
|
test: (post: Post) => boolean }
|
||||||
|
|
||||||
|
export type BuildGekanatorQuestionsOptions = {
|
||||||
|
includeTitleContains?: boolean
|
||||||
|
tagQuestionCap?: number
|
||||||
|
titleContainsCap?: number
|
||||||
|
totalQuestionCap?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const normalizeTitleLengthCondition = (
|
||||||
|
condition: GekanatorQuestionCondition,
|
||||||
|
): GekanatorQuestionCondition => {
|
||||||
|
switch (condition.type)
|
||||||
|
{
|
||||||
|
case 'title-length-greater-than':
|
||||||
|
return {
|
||||||
|
type: 'title-length-at-least',
|
||||||
|
length: condition.length + 1 }
|
||||||
|
default:
|
||||||
|
return condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const titleLengthMinimumForCondition = (
|
||||||
|
condition: GekanatorQuestionCondition,
|
||||||
|
): number | null => {
|
||||||
|
switch (condition.type)
|
||||||
|
{
|
||||||
|
case 'title-length-at-least':
|
||||||
|
return condition.length
|
||||||
|
case 'title-length-greater-than':
|
||||||
|
return condition.length + 1
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const questionIdForCondition = (
|
||||||
|
condition: NonPostSimilarityCondition,
|
||||||
|
): string => {
|
||||||
|
switch (condition.type)
|
||||||
|
{
|
||||||
|
case 'tag':
|
||||||
|
return `tag:${ condition.key }`
|
||||||
|
case 'source':
|
||||||
|
return `source:${ condition.host }`
|
||||||
|
case 'original-year':
|
||||||
|
return `original-year:${ condition.year }`
|
||||||
|
case 'original-month':
|
||||||
|
return `original-month:${ condition.month }`
|
||||||
|
case 'original-month-day':
|
||||||
|
return `original-month-day:${ condition.monthDay }`
|
||||||
|
case 'title-length-at-least':
|
||||||
|
case 'title-length-greater-than':
|
||||||
|
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
|
||||||
|
case 'title-has-ascii':
|
||||||
|
return 'title:ascii'
|
||||||
|
case 'title-contains':
|
||||||
|
return `title:contains:${ condition.text }`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const directExampleAnswerFor = (
|
||||||
|
question: StoredGekanatorQuestion,
|
||||||
|
post: Post,
|
||||||
|
): GekanatorAnswerValue | null => {
|
||||||
|
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
||||||
|
if (direct)
|
||||||
|
return direct
|
||||||
|
|
||||||
|
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
|
||||||
|
return question.condition.answer
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const isLearnedSemanticQuestion = (
|
||||||
|
question: StoredGekanatorQuestion | GekanatorQuestion,
|
||||||
|
): boolean =>
|
||||||
|
question.kind === 'post_similarity'
|
||||||
|
&& question.source === 'user_suggested'
|
||||||
|
|
||||||
|
|
||||||
|
export const learnedSemanticSideForAnswer = (
|
||||||
|
answer: GekanatorAnswerValue | null,
|
||||||
|
): LearnedSemanticSide => {
|
||||||
|
if (answer === 'yes' || answer === 'partial')
|
||||||
|
return 'positive'
|
||||||
|
|
||||||
|
if (answer === 'no' || answer === 'probably_no')
|
||||||
|
return 'negative'
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
|
const counts = new Map<T, number> ()
|
||||||
|
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const median = (values: number[]): number => {
|
||||||
|
const sorted = [...values].sort ((a, b) => a - b)
|
||||||
|
return sorted[Math.floor (sorted.length / 2)] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const hostOf = (post: Post): string | null => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new URL (post.url).hostname.replace (/^www\./, '')
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const originalYearOf = (post: Post): number | null => {
|
||||||
|
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
||||||
|
if (!(value))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const date = new Date (value)
|
||||||
|
if (Number.isNaN (date.getTime ()))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return date.getFullYear ()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const originalDateOf = (post: Post): Date | null => {
|
||||||
|
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
||||||
|
if (!(value))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const date = new Date (value)
|
||||||
|
if (Number.isNaN (date.getTime ()))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const originalMonthOf = (post: Post): number | null => {
|
||||||
|
const date = originalDateOf (post)
|
||||||
|
if (!(date))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return date.getMonth () + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const originalMonthDayOf = (post: Post): string | null => {
|
||||||
|
const date = originalDateOf (post)
|
||||||
|
if (!(date))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return `${ date.getMonth () + 1 }-${ date.getDate () }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
|
||||||
|
`${ category }:${ name }`
|
||||||
|
|
||||||
|
|
||||||
|
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
|
||||||
|
const [category, ...rest] = key.split (':')
|
||||||
|
return { category: category ?? '', name: rest.join (':') }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
|
||||||
|
|
||||||
|
|
||||||
|
const tagQuestionText = (category: string, label: string): string => {
|
||||||
|
switch (category)
|
||||||
|
{
|
||||||
|
case 'deerjikist':
|
||||||
|
return `作者・ニジラーとして「${ label }」に関係してゐる?`
|
||||||
|
case 'meme':
|
||||||
|
return `元ネタ・ミームとして「${ label }」に関係しさう?`
|
||||||
|
case 'character':
|
||||||
|
return `「${ label }」といふキャラクターが関係してゐる?`
|
||||||
|
case 'material':
|
||||||
|
return `素材として「${ label }」に関係してゐる?`
|
||||||
|
case 'nico':
|
||||||
|
return `ニコニコに「${ label }」といふタグが付いてゐる?`
|
||||||
|
case 'general':
|
||||||
|
case 'meta':
|
||||||
|
default:
|
||||||
|
return `内容として「${ label }」に関係しさう?`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const questionableTag = (post: Post, key: string): boolean => {
|
||||||
|
const { category, name } = tagFromQuestionKey (key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
post.tags.some (tag =>
|
||||||
|
tag.name === name
|
||||||
|
&& tag.category === category
|
||||||
|
&& !(tag.category === 'meta')
|
||||||
|
&& !(tag.name.includes ('タグ希望'))
|
||||||
|
&& !(tag.name.includes ('bot操作'))))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const questionMatches = (
|
||||||
|
post: Post,
|
||||||
|
question: StoredGekanatorQuestion,
|
||||||
|
): boolean => {
|
||||||
|
const directAnswer = directExampleAnswerFor (question, post)
|
||||||
|
if (directAnswer)
|
||||||
|
return question.kind === 'post_similarity'
|
||||||
|
? learnedSemanticSideForAnswer (directAnswer) === 'positive'
|
||||||
|
: directAnswer === 'yes'
|
||||||
|
|
||||||
|
switch (question.condition.type)
|
||||||
|
{
|
||||||
|
case 'tag':
|
||||||
|
return questionableTag (post, question.condition.key)
|
||||||
|
case 'source':
|
||||||
|
return hostOf (post) === question.condition.host
|
||||||
|
case 'original-year':
|
||||||
|
return originalYearOf (post) === question.condition.year
|
||||||
|
case 'original-month':
|
||||||
|
return originalMonthOf (post) === question.condition.month
|
||||||
|
case 'original-month-day':
|
||||||
|
return originalMonthDayOf (post) === question.condition.monthDay
|
||||||
|
case 'title-length-at-least':
|
||||||
|
return (post.title?.length ?? 0) >= question.condition.length
|
||||||
|
case 'title-length-greater-than':
|
||||||
|
return (post.title?.length ?? 0) > question.condition.length
|
||||||
|
case 'title-has-ascii':
|
||||||
|
return /[A-Za-z0-9]/.test (post.title ?? '')
|
||||||
|
case 'title-contains':
|
||||||
|
return (post.title ?? '').includes (question.condition.text)
|
||||||
|
case 'post-similarity':
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const expectedAnswerForQuestion = (
|
||||||
|
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||||
|
post: Post | null,
|
||||||
|
): GekanatorAnswerValue | null => {
|
||||||
|
if (!(question) || !(post))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const directAnswer = directExampleAnswerFor (question, post)
|
||||||
|
if (directAnswer)
|
||||||
|
return directAnswer
|
||||||
|
|
||||||
|
switch (question.condition.type)
|
||||||
|
{
|
||||||
|
case 'post-similarity':
|
||||||
|
if (question.condition.postId === post.id)
|
||||||
|
return question.condition.answer
|
||||||
|
|
||||||
|
return null
|
||||||
|
case 'tag':
|
||||||
|
case 'source':
|
||||||
|
case 'original-year':
|
||||||
|
case 'original-month':
|
||||||
|
case 'original-month-day':
|
||||||
|
case 'title-length-at-least':
|
||||||
|
case 'title-length-greater-than':
|
||||||
|
case 'title-has-ascii':
|
||||||
|
case 'title-contains':
|
||||||
|
return questionMatches (post, question) ? 'yes' : 'no'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const learnedSemanticSideForPost = (
|
||||||
|
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||||
|
post: Post | null,
|
||||||
|
): LearnedSemanticSide =>
|
||||||
|
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
|
||||||
|
|
||||||
|
|
||||||
|
export const restoreGekanatorQuestion = (
|
||||||
|
question: StoredGekanatorQuestion,
|
||||||
|
): GekanatorQuestion => {
|
||||||
|
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
|
||||||
|
const normalizedQuestion = {
|
||||||
|
...question,
|
||||||
|
recordId: question.recordId,
|
||||||
|
id: normalizedCondition.type === 'title-length-at-least'
|
||||||
|
? `title:length-at-least:${ normalizedCondition.length }`
|
||||||
|
: question.id,
|
||||||
|
condition: normalizedCondition,
|
||||||
|
source: question.source ?? 'default',
|
||||||
|
priorityWeight: question.priorityWeight ?? 1 }
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedQuestion,
|
||||||
|
test: (post: Post) => questionMatches (post, normalizedQuestion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const storeGekanatorQuestion = (
|
||||||
|
question: GekanatorQuestion,
|
||||||
|
): StoredGekanatorQuestion => ({
|
||||||
|
id: question.condition.type === 'title-length-greater-than'
|
||||||
|
? `title:length-at-least:${ question.condition.length + 1 }`
|
||||||
|
: question.id,
|
||||||
|
recordId: question.recordId,
|
||||||
|
text: question.text,
|
||||||
|
kind: question.kind,
|
||||||
|
condition: normalizeTitleLengthCondition (question.condition),
|
||||||
|
source: question.source,
|
||||||
|
priorityWeight: question.priorityWeight,
|
||||||
|
exampleAnswers: question.exampleAnswers })
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||||
|
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
||||||
|
return data.posts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
||||||
|
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
|
||||||
|
return data.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorExtraQuestions = async (
|
||||||
|
gameId: number,
|
||||||
|
nonce?: string,
|
||||||
|
): Promise<GekanatorExtraQuestion[]> => {
|
||||||
|
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||||
|
`/gekanator/games/${ gameId }/extra_questions`,
|
||||||
|
{ params: nonce ? { nonce } : undefined })
|
||||||
|
return data.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const buildGekanatorQuestions = (
|
||||||
|
posts: Post[],
|
||||||
|
options: BuildGekanatorQuestionsOptions = { },
|
||||||
|
): GekanatorQuestion[] => {
|
||||||
|
const {
|
||||||
|
includeTitleContains = true,
|
||||||
|
tagQuestionCap = 192,
|
||||||
|
titleContainsCap = 24,
|
||||||
|
totalQuestionCap = Number.POSITIVE_INFINITY,
|
||||||
|
} = options
|
||||||
|
const tagCounts = countBy (posts.flatMap (post =>
|
||||||
|
post.tags
|
||||||
|
.filter (tag =>
|
||||||
|
!(tag.category === 'meta')
|
||||||
|
&& !(tag.name.includes ('タグ希望'))
|
||||||
|
&& !(tag.name.includes ('bot操作')))
|
||||||
|
.map (tag => tagQuestionKey (tag))))
|
||||||
|
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
|
||||||
|
const originalYears = countBy (
|
||||||
|
posts
|
||||||
|
.map (originalYearOf)
|
||||||
|
.filter ((year): year is number => year != null))
|
||||||
|
const originalMonths = countBy (
|
||||||
|
posts
|
||||||
|
.map (originalMonthOf)
|
||||||
|
.filter ((month): month is number => month != null))
|
||||||
|
const originalMonthDays = countBy (
|
||||||
|
posts
|
||||||
|
.map (originalMonthDayOf)
|
||||||
|
.filter ((monthDay): monthDay is string => monthDay != null))
|
||||||
|
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
||||||
|
const titleWordCounts =
|
||||||
|
includeTitleContains
|
||||||
|
? countBy (
|
||||||
|
posts.flatMap (post =>
|
||||||
|
Array.from (
|
||||||
|
new Set (
|
||||||
|
(post.title ?? '')
|
||||||
|
.match (
|
||||||
|
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu)
|
||||||
|
?? []))))
|
||||||
|
: new Map<string, number> ()
|
||||||
|
|
||||||
|
const usefulEntries = <T extends string | number> (
|
||||||
|
counts: Map<T, number>,
|
||||||
|
cap: number,
|
||||||
|
) =>
|
||||||
|
[...counts.entries ()]
|
||||||
|
.filter (([, count]) => count > 0 && count < posts.length)
|
||||||
|
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
|
||||||
|
- Math.abs (posts.length / 2 - b[1]))
|
||||||
|
.slice (0, cap)
|
||||||
|
|
||||||
|
const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, tagQuestionCap)
|
||||||
|
.map (([key]) => {
|
||||||
|
const { category, name } = tagFromQuestionKey (String (key))
|
||||||
|
const label = category === 'nico' ? nicoTagLabel (name) : name
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tag:${ key }`,
|
||||||
|
text: tagQuestionText (category, label),
|
||||||
|
kind: 'tag' as const,
|
||||||
|
condition: { type: 'tag' as const, key: String (key) },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => questionableTag (post, String (key)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceQuestions = usefulEntries (hosts, 20)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 20)
|
||||||
|
.map (([host]) => ({
|
||||||
|
id: `source:${ host }`,
|
||||||
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||||
|
kind: 'source' as const,
|
||||||
|
condition: { type: 'source' as const, host },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => hostOf (post) === host }))
|
||||||
|
|
||||||
|
const originalYearQuestions = usefulEntries (originalYears, 20)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 20)
|
||||||
|
.map (([year]) => ({
|
||||||
|
id: `original-year:${ year }`,
|
||||||
|
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||||
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-year' as const, year },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => originalYearOf (post) === year }))
|
||||||
|
|
||||||
|
const originalMonthQuestions = usefulEntries (originalMonths, 20)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 20)
|
||||||
|
.map (([month]) => ({
|
||||||
|
id: `original-month:${ month }`,
|
||||||
|
text: `オリジナルの投稿月は ${ month } 月?`,
|
||||||
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-month' as const, month },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => originalMonthOf (post) === month }))
|
||||||
|
|
||||||
|
const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 20)
|
||||||
|
.map (([monthDay]) => {
|
||||||
|
const [month, day] = String (monthDay).split ('-')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `original-month-day:${ monthDay }`,
|
||||||
|
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||||||
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleQuestions = [
|
||||||
|
{
|
||||||
|
id: `title:length-at-least:${ titleLengthMedian }`,
|
||||||
|
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
||||||
|
kind: 'title' as const,
|
||||||
|
condition: {
|
||||||
|
type: 'title-length-at-least' as const,
|
||||||
|
length: titleLengthMedian },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
|
||||||
|
{
|
||||||
|
id: 'title:ascii',
|
||||||
|
text: '題名に英数字が混じってゐる?',
|
||||||
|
kind: 'title' as const,
|
||||||
|
condition: { type: 'title-has-ascii' as const },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
||||||
|
.filter (question => {
|
||||||
|
const yes = posts.filter (post => question.test (post)).length
|
||||||
|
const no = posts.length - yes
|
||||||
|
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
|
||||||
|
})
|
||||||
|
const titleContainsQuestions =
|
||||||
|
includeTitleContains
|
||||||
|
? usefulEntries (titleWordCounts, titleContainsCap)
|
||||||
|
.filter (([word, count]) =>
|
||||||
|
String (word).length <= 24
|
||||||
|
&& count >= 2
|
||||||
|
&& count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, titleContainsCap)
|
||||||
|
.map (([word]) => ({
|
||||||
|
id: `title:contains:${ word }`,
|
||||||
|
text: `題名に「${ word }」が含まれる?`,
|
||||||
|
kind: 'title' as const,
|
||||||
|
condition: { type: 'title-contains' as const, text: String (word) },
|
||||||
|
source: 'default' as const,
|
||||||
|
priorityWeight: .96,
|
||||||
|
test: (post: Post) => (post.title ?? '').includes (String (word)) }))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return [
|
||||||
|
...sourceQuestions,
|
||||||
|
...originalYearQuestions,
|
||||||
|
...originalMonthQuestions,
|
||||||
|
...originalMonthDayQuestions,
|
||||||
|
...titleQuestions,
|
||||||
|
...titleContainsQuestions,
|
||||||
|
...tagQuestions].slice (0, totalQuestionCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const saveGekanatorGame = async ({
|
||||||
|
guessedPostId,
|
||||||
|
correctPostId,
|
||||||
|
answers,
|
||||||
|
}: {
|
||||||
|
guessedPostId: number
|
||||||
|
correctPostId: number
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
}): Promise<{ id: number; learnedExampleCount: number }> =>
|
||||||
|
await apiPost ('/gekanator/games', {
|
||||||
|
guessed_post_id: guessedPostId,
|
||||||
|
correct_post_id: correctPostId,
|
||||||
|
answers: answers.map (answer => ({
|
||||||
|
question_id: answer.questionId,
|
||||||
|
question_text: answer.questionText,
|
||||||
|
question_condition: answer.questionCondition ?? null,
|
||||||
|
question_mode: answer.questionMode,
|
||||||
|
question_purpose: answer.questionPurpose,
|
||||||
|
effective_question: answer.effectiveQuestion,
|
||||||
|
learning_question: answer.learningQuestion,
|
||||||
|
answer: answer.answer,
|
||||||
|
original_answer: answer.originalAnswer })) })
|
||||||
|
|
||||||
|
|
||||||
|
export const saveGekanatorQuestionSuggestion = async ({
|
||||||
|
gekanatorGameId,
|
||||||
|
existingQuestionId,
|
||||||
|
questionText,
|
||||||
|
answer,
|
||||||
|
}: {
|
||||||
|
gekanatorGameId: number
|
||||||
|
existingQuestionId?: number
|
||||||
|
questionText?: string
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
|
}): Promise<{ id: number; count: number }> =>
|
||||||
|
await apiPost ('/gekanator/question_suggestions', {
|
||||||
|
gekanator_game_id: gekanatorGameId,
|
||||||
|
existing_question_id: existingQuestionId,
|
||||||
|
question_text: questionText,
|
||||||
|
answer })
|
||||||
|
|
||||||
|
|
||||||
|
export const saveGekanatorExtraQuestionAnswers = async ({
|
||||||
|
gameId,
|
||||||
|
answers,
|
||||||
|
}: {
|
||||||
|
gameId: number
|
||||||
|
answers: { questionId: number; answer: GekanatorAnswerValue }[]
|
||||||
|
}) =>
|
||||||
|
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
|
||||||
|
answers: answers.map (item => ({
|
||||||
|
question_id: item.questionId,
|
||||||
|
answer: item.answer })) })
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
candidatePostsFor,
|
||||||
|
hardFilteredPostsForAnswer,
|
||||||
|
recoverCandidatePosts,
|
||||||
|
} from '@/lib/gekanatorCandidateRecovery'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GekanatorAnswerLog,
|
||||||
|
GekanatorAnswerValue,
|
||||||
|
GekanatorQuestion,
|
||||||
|
} from '@/lib/gekanator'
|
||||||
|
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
const post = (id: number): Post => ({
|
||||||
|
id,
|
||||||
|
versionNo: 1,
|
||||||
|
url: `https://example.com/posts/${ id }`,
|
||||||
|
title: `post ${ id }`,
|
||||||
|
thumbnail: null,
|
||||||
|
thumbnailBase: null,
|
||||||
|
tags: [],
|
||||||
|
viewed: false,
|
||||||
|
related: [],
|
||||||
|
originalCreatedFrom: null,
|
||||||
|
originalCreatedBefore: null,
|
||||||
|
createdAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
uploadedUser: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const postSimilarityQuestion = (
|
||||||
|
id: string,
|
||||||
|
answers: Record<`${ number }`, GekanatorAnswerValue>,
|
||||||
|
): GekanatorQuestion => ({
|
||||||
|
id,
|
||||||
|
text: `${ id }?`,
|
||||||
|
kind: 'post_similarity',
|
||||||
|
condition: {
|
||||||
|
type: 'post-similarity',
|
||||||
|
postId: 9999,
|
||||||
|
answer: 'yes',
|
||||||
|
threshold: 0.65 },
|
||||||
|
source: 'user_suggested',
|
||||||
|
priorityWeight: 1,
|
||||||
|
exampleAnswers: answers,
|
||||||
|
test: candidate => answers[String (candidate.id) as `${ number }`] === 'yes',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const sourceQuestion = (
|
||||||
|
host: string,
|
||||||
|
): GekanatorQuestion => ({
|
||||||
|
id: `source:${ host }`,
|
||||||
|
text: `${ host }?`,
|
||||||
|
kind: 'source',
|
||||||
|
condition: {
|
||||||
|
type: 'source',
|
||||||
|
host },
|
||||||
|
source: 'default',
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: candidate => new URL (candidate.url).hostname === host,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const answer = (
|
||||||
|
question: GekanatorQuestion,
|
||||||
|
value: GekanatorAnswerValue,
|
||||||
|
): GekanatorAnswerLog => ({
|
||||||
|
questionId: question.id,
|
||||||
|
questionText: question.text,
|
||||||
|
questionCondition: question.condition,
|
||||||
|
answer: value,
|
||||||
|
originalAnswer: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const recoveredState = (
|
||||||
|
answerCountAtRecovery: number,
|
||||||
|
scoreAtRecovery = 0,
|
||||||
|
): RecoveredCandidateState => ({
|
||||||
|
answerCountAtRecovery,
|
||||||
|
scoreAtRecovery,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('candidatePostsFor', () => {
|
||||||
|
it('does not hard-filter semantic post_similarity answers', () => {
|
||||||
|
const posts = [post (1), post (2), post (3)]
|
||||||
|
const oldQuestion = postSimilarityQuestion ('old', {
|
||||||
|
1: 'no',
|
||||||
|
2: 'yes',
|
||||||
|
3: 'yes',
|
||||||
|
})
|
||||||
|
const laterQuestion = postSimilarityQuestion ('later', {
|
||||||
|
1: 'no',
|
||||||
|
2: 'no',
|
||||||
|
3: 'yes',
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidates = candidatePostsFor ({
|
||||||
|
posts,
|
||||||
|
questions: [oldQuestion, laterQuestion],
|
||||||
|
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
|
||||||
|
softenedQuestionIds: new Set (),
|
||||||
|
rejectedPostIds: new Set (),
|
||||||
|
recoveredCandidatePosts: new Map ([
|
||||||
|
[1, recoveredState (1)],
|
||||||
|
[3, recoveredState (1)],
|
||||||
|
]) })
|
||||||
|
|
||||||
|
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets recovered candidates ignore old fact answers but not later fact answers', () => {
|
||||||
|
const posts = [
|
||||||
|
{ ...post (1), url: 'https://other.example/posts/1' },
|
||||||
|
post (2),
|
||||||
|
{ ...post (3), url: 'https://example.com/posts/3' },
|
||||||
|
]
|
||||||
|
const oldQuestion = sourceQuestion ('old.example.com')
|
||||||
|
const laterQuestion = sourceQuestion ('example.com')
|
||||||
|
|
||||||
|
const candidates = candidatePostsFor ({
|
||||||
|
posts,
|
||||||
|
questions: [oldQuestion, laterQuestion],
|
||||||
|
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
|
||||||
|
softenedQuestionIds: new Set (),
|
||||||
|
rejectedPostIds: new Set (),
|
||||||
|
recoveredCandidatePosts: new Map ([
|
||||||
|
[1, recoveredState (1)],
|
||||||
|
[3, recoveredState (1)],
|
||||||
|
]) })
|
||||||
|
|
||||||
|
expect(candidates.map (candidate => candidate.id)).toEqual ([3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let recovered candidates bypass explicit rejected posts', () => {
|
||||||
|
const posts = [post (1), post (2)]
|
||||||
|
const question = postSimilarityQuestion ('question', {
|
||||||
|
1: 'yes',
|
||||||
|
2: 'yes',
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidates = candidatePostsFor ({
|
||||||
|
posts,
|
||||||
|
questions: [question],
|
||||||
|
answers: [answer (question, 'yes')],
|
||||||
|
softenedQuestionIds: new Set (),
|
||||||
|
rejectedPostIds: new Set ([1]),
|
||||||
|
recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
|
||||||
|
|
||||||
|
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('hardFilteredPostsForAnswer', () => {
|
||||||
|
it('keeps the original pool for semantic post_similarity answers', () => {
|
||||||
|
const posts = [post (1), post (2)]
|
||||||
|
const question = postSimilarityQuestion ('question', {
|
||||||
|
1: 'yes',
|
||||||
|
2: 'yes',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'no',
|
||||||
|
})).toEqual (posts)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hard-filters fact answers only for yes and no', () => {
|
||||||
|
const posts = [
|
||||||
|
{ ...post (1), url: 'https://example.com/posts/1' },
|
||||||
|
{ ...post (2), url: 'https://other.example/posts/2' },
|
||||||
|
]
|
||||||
|
const question = sourceQuestion ('example.com')
|
||||||
|
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'yes',
|
||||||
|
}).map (candidate => candidate.id)).toEqual ([1])
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'no',
|
||||||
|
}).map (candidate => candidate.id)).toEqual ([2])
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'partial',
|
||||||
|
})).toEqual (posts)
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'probably_no',
|
||||||
|
})).toEqual (posts)
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'unknown',
|
||||||
|
})).toEqual (posts)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('recoverCandidatePosts', () => {
|
||||||
|
it('recovers high-score non-rejected, non-eligible candidates in staged batches', () => {
|
||||||
|
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
|
||||||
|
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
|
||||||
|
|
||||||
|
const recovered = recoverCandidatePosts ({
|
||||||
|
posts,
|
||||||
|
scores,
|
||||||
|
rejectedPostIds: new Set ([10]),
|
||||||
|
recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
|
||||||
|
eligiblePostIds: new Set ([9]),
|
||||||
|
answerCountAtRecovery: 2,
|
||||||
|
recoveryStepCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(recovered?.recoveryStepCount).toBe (1)
|
||||||
|
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
|
||||||
|
.toEqual ([8, 7, 6, 5, 4])
|
||||||
|
expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({
|
||||||
|
answerCountAtRecovery: 2,
|
||||||
|
scoreAtRecovery: 7,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add posts when recovered and eligible candidates already hit the target', () => {
|
||||||
|
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
|
||||||
|
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
|
||||||
|
|
||||||
|
const recovered = recoverCandidatePosts ({
|
||||||
|
posts,
|
||||||
|
scores,
|
||||||
|
rejectedPostIds: new Set (),
|
||||||
|
recoveredCandidatePosts: new Map ([
|
||||||
|
[1, recoveredState (1, 1)],
|
||||||
|
[2, recoveredState (1, 2)],
|
||||||
|
[3, recoveredState (1, 3)],
|
||||||
|
]),
|
||||||
|
eligiblePostIds: new Set ([4, 5, 6]),
|
||||||
|
answerCountAtRecovery: 2,
|
||||||
|
recoveryStepCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(recovered?.recoveryStepCount).toBe (1)
|
||||||
|
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
|
||||||
|
.toEqual ([1, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { isLearnedSemanticQuestion,
|
||||||
|
learnedSemanticSideForPost } from '@/lib/gekanator'
|
||||||
|
|
||||||
|
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
export type RecoveredCandidatePost = {
|
||||||
|
postId: number
|
||||||
|
answerCountAtRecovery: number
|
||||||
|
scoreAtRecovery: number }
|
||||||
|
|
||||||
|
export type RecoveredCandidateState = {
|
||||||
|
answerCountAtRecovery: number
|
||||||
|
scoreAtRecovery: number }
|
||||||
|
|
||||||
|
|
||||||
|
const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
|
||||||
|
!(isLearnedSemanticQuestion (question)
|
||||||
|
|| (question.kind === 'tag'
|
||||||
|
&& question.condition.type === 'tag'
|
||||||
|
&& !(question.condition.key.startsWith ('nico:'))))
|
||||||
|
|
||||||
|
|
||||||
|
export const candidatePostsFor = (
|
||||||
|
{ posts,
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
softenedQuestionIds,
|
||||||
|
rejectedPostIds,
|
||||||
|
recoveredCandidatePosts }: { posts: Post[]
|
||||||
|
questions: GekanatorQuestion[]
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
softenedQuestionIds: Set<string>
|
||||||
|
rejectedPostIds: Set<number>
|
||||||
|
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
|
||||||
|
): Post[] => {
|
||||||
|
const questionById = new Map (questions.map (question => [question.id, question]))
|
||||||
|
|
||||||
|
return posts.filter (post => {
|
||||||
|
if (rejectedPostIds.has (post.id))
|
||||||
|
return false
|
||||||
|
|
||||||
|
const recoveredCandidate = recoveredCandidatePosts.get (post.id)
|
||||||
|
|
||||||
|
return answers.every ((answer, index) => {
|
||||||
|
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
|
||||||
|
return true
|
||||||
|
|
||||||
|
if (softenedQuestionIds.has (answer.questionId))
|
||||||
|
return true
|
||||||
|
|
||||||
|
const question = questionById.get (answer.questionId)
|
||||||
|
if (!(question))
|
||||||
|
return true
|
||||||
|
if (!(questionSupportsAnswerBasedHardFiltering (question)))
|
||||||
|
return true
|
||||||
|
|
||||||
|
switch (answer.answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
case 'no':
|
||||||
|
{
|
||||||
|
const expected = learnedSemanticSideForPost (question, post)
|
||||||
|
return expected === 'unknown'
|
||||||
|
|| (answer.answer === 'yes' && expected === 'positive')
|
||||||
|
|| (answer.answer === 'no' && expected === 'negative')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const hardFilteredPostsForAnswer = (
|
||||||
|
{ posts, question, answer }: { posts: Post[]
|
||||||
|
question: GekanatorQuestion
|
||||||
|
answer: GekanatorAnswerValue },
|
||||||
|
): Post[] => {
|
||||||
|
if (!(questionSupportsAnswerBasedHardFiltering (question)))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
if (!(answer === 'yes' || answer === 'no'))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
return posts.filter (post => {
|
||||||
|
const side = learnedSemanticSideForPost (question, post)
|
||||||
|
return side === 'unknown'
|
||||||
|
|| (answer === 'yes' && side === 'positive')
|
||||||
|
|| (answer === 'no' && side === 'negative')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no']
|
||||||
|
|
||||||
|
|
||||||
|
export const allConcreteAnswerOptionsExhausted = (
|
||||||
|
posts: Post[],
|
||||||
|
question: GekanatorQuestion | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!(question))
|
||||||
|
return false
|
||||||
|
|
||||||
|
return concreteAnswerOptions.every (answer =>
|
||||||
|
hardFilteredPostsForAnswer ({ posts, question, answer }).length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
|
||||||
|
6 * (2 ** recoveryStepCount)
|
||||||
|
|
||||||
|
|
||||||
|
export const recoverCandidatePosts = (
|
||||||
|
{ posts,
|
||||||
|
scores,
|
||||||
|
rejectedPostIds,
|
||||||
|
recoveredCandidatePosts,
|
||||||
|
eligiblePostIds,
|
||||||
|
answerCountAtRecovery,
|
||||||
|
recoveryStepCount }: { posts: Post[]
|
||||||
|
scores: Map<number, number>
|
||||||
|
rejectedPostIds: Set<number>
|
||||||
|
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||||||
|
eligiblePostIds: Set<number>
|
||||||
|
answerCountAtRecovery: number
|
||||||
|
recoveryStepCount: number },
|
||||||
|
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||||||
|
recoveryStepCount: number } | null => {
|
||||||
|
const recovered = new Map (recoveredCandidatePosts)
|
||||||
|
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
|
||||||
|
const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()])
|
||||||
|
const addCount = targetSize - countedPostIds.size
|
||||||
|
if (addCount <= 0)
|
||||||
|
{
|
||||||
|
return { recoveredCandidatePosts: recovered,
|
||||||
|
recoveryStepCount: recoveryStepCount + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
posts
|
||||||
|
.filter (post => (!(rejectedPostIds.has (post.id))
|
||||||
|
&& !(eligiblePostIds.has (post.id))
|
||||||
|
&& !(recovered.has (post.id))))
|
||||||
|
.sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
|
||||||
|
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)))
|
||||||
|
.slice (0, addCount)
|
||||||
|
|
||||||
|
if (candidates.length === 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
candidates.forEach (post => recovered.set (post.id, {
|
||||||
|
answerCountAtRecovery,
|
||||||
|
scoreAtRecovery: scores.get (post.id) ?? 0 }))
|
||||||
|
|
||||||
|
return { recoveredCandidatePosts: recovered,
|
||||||
|
recoveryStepCount: recoveryStepCount + 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { titleLengthMinimumForCondition } from '@/lib/gekanator'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GekanatorAnswerLog,
|
||||||
|
GekanatorAnswerValue,
|
||||||
|
GekanatorQuestion,
|
||||||
|
} from '@/lib/gekanator'
|
||||||
|
|
||||||
|
|
||||||
|
export const monthForCondition = (
|
||||||
|
condition: GekanatorQuestion['condition'],
|
||||||
|
): number | null => {
|
||||||
|
if (condition.type === 'original-month')
|
||||||
|
return condition.month
|
||||||
|
|
||||||
|
if (condition.type !== 'original-month-day')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const month = Number (condition.monthDay.split ('-')[0])
|
||||||
|
return Number.isInteger (month) ? month : null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isTitleLengthContradiction = (
|
||||||
|
candidate: GekanatorQuestion['condition'],
|
||||||
|
previous: GekanatorQuestion['condition'],
|
||||||
|
answer: GekanatorAnswerValue,
|
||||||
|
): boolean => {
|
||||||
|
const candidateLength = titleLengthMinimumForCondition (candidate)
|
||||||
|
const previousLength = titleLengthMinimumForCondition (previous)
|
||||||
|
if (candidateLength === null || previousLength === null)
|
||||||
|
return false
|
||||||
|
|
||||||
|
switch (answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
return candidateLength <= previousLength
|
||||||
|
case 'no':
|
||||||
|
return candidateLength >= previousLength
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isQuestionRedundantAfterAnswers = (
|
||||||
|
question: GekanatorQuestion,
|
||||||
|
answers: GekanatorAnswerLog[],
|
||||||
|
): boolean => answers.some (answer => {
|
||||||
|
const previous = answer.questionCondition
|
||||||
|
return previous !== undefined
|
||||||
|
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const isSourceFactBlocked = (
|
||||||
|
candidate: GekanatorQuestion['condition'],
|
||||||
|
previous: GekanatorQuestion['condition'],
|
||||||
|
answer: GekanatorAnswerValue,
|
||||||
|
): boolean => {
|
||||||
|
if (candidate.type !== 'source' || previous.type !== 'source')
|
||||||
|
return false
|
||||||
|
|
||||||
|
switch (answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
return true
|
||||||
|
case 'no':
|
||||||
|
return candidate.host === previous.host
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isOriginalYearFactBlocked = (
|
||||||
|
candidate: GekanatorQuestion['condition'],
|
||||||
|
previous: GekanatorQuestion['condition'],
|
||||||
|
answer: GekanatorAnswerValue,
|
||||||
|
): boolean => {
|
||||||
|
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
|
||||||
|
return false
|
||||||
|
|
||||||
|
switch (answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
return true
|
||||||
|
case 'no':
|
||||||
|
return candidate.year === previous.year
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isOriginalMonthFactBlocked = (
|
||||||
|
candidate: GekanatorQuestion['condition'],
|
||||||
|
previous: GekanatorQuestion['condition'],
|
||||||
|
answer: GekanatorAnswerValue,
|
||||||
|
): boolean => {
|
||||||
|
switch (answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
if (previous.type === 'original-month')
|
||||||
|
{
|
||||||
|
if (candidate.type === 'original-month')
|
||||||
|
return true
|
||||||
|
|
||||||
|
if (candidate.type === 'original-month-day')
|
||||||
|
return monthForCondition (candidate) !== previous.month
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.type === 'original-month-day')
|
||||||
|
return candidate.type === 'original-month'
|
||||||
|
|| candidate.type === 'original-month-day'
|
||||||
|
|
||||||
|
return false
|
||||||
|
case 'no':
|
||||||
|
if (previous.type === 'original-month')
|
||||||
|
{
|
||||||
|
if (candidate.type === 'original-month')
|
||||||
|
return candidate.month === previous.month
|
||||||
|
|
||||||
|
if (candidate.type === 'original-month-day')
|
||||||
|
return monthForCondition (candidate) === previous.month
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.type === 'original-month-day')
|
||||||
|
return candidate.type === 'original-month-day'
|
||||||
|
&& candidate.monthDay === previous.monthDay
|
||||||
|
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isFactQuestionBlocked = (
|
||||||
|
candidate: GekanatorQuestion['condition'],
|
||||||
|
previous: GekanatorQuestion['condition'],
|
||||||
|
answer: GekanatorAnswerValue,
|
||||||
|
): boolean => {
|
||||||
|
if (!(answer === 'yes' || answer === 'no'))
|
||||||
|
return false
|
||||||
|
|
||||||
|
return isSourceFactBlocked (candidate, previous, answer)
|
||||||
|
|| isOriginalYearFactBlocked (candidate, previous, answer)
|
||||||
|
|| isOriginalMonthFactBlocked (candidate, previous, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const isQuestionHardFilteredAfterAnswers = (
|
||||||
|
question: GekanatorQuestion,
|
||||||
|
answers: GekanatorAnswerLog[],
|
||||||
|
): boolean => answers.some (answer => {
|
||||||
|
const previous = answer.questionCondition
|
||||||
|
if (previous === undefined)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return isQuestionRedundantAfterAnswers (question, [answer])
|
||||||
|
|| isFactQuestionBlocked (question.condition, previous, answer.answer)
|
||||||
|
})
|
||||||
@@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title')
|
|||||||
const mTag = match<{ id: string }> ('/tags/:id')
|
const mTag = match<{ id: string }> ('/tags/:id')
|
||||||
|
|
||||||
|
|
||||||
|
const boolFromQuery = (value: string | null): boolean =>
|
||||||
|
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||||
|
|
||||||
|
|
||||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||||
const title = url.searchParams.get ('title') ?? ''
|
const title = url.searchParams.get ('title') ?? ''
|
||||||
|
|
||||||
@@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
|
|||||||
const createdTo = url.searchParams.get ('created_to') ?? ''
|
const createdTo = url.searchParams.get ('created_to') ?? ''
|
||||||
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
|
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
|
||||||
const updatedTo = url.searchParams.get ('updated_to') ?? ''
|
const updatedTo = url.searchParams.get ('updated_to') ?? ''
|
||||||
|
const deprecated = url.searchParams.has ('deprecated')
|
||||||
|
? boolFromQuery (url.searchParams.get ('deprecated'))
|
||||||
|
: null
|
||||||
const page = Number (url.searchParams.get ('page') || 1)
|
const page = Number (url.searchParams.get ('page') || 1)
|
||||||
const limit = Number (url.searchParams.get ('limit') || 20)
|
const limit = Number (url.searchParams.get ('limit') || 20)
|
||||||
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
|
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
|
||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, page, limit, order }
|
updatedFrom, updatedTo, deprecated, page, limit, order }
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: tagsKeys.index (keys),
|
queryKey: tagsKeys.index (keys),
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export const postsKeys = {
|
|||||||
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
||||||
['posts', 'changes', p] as const }
|
['posts', 'changes', p] as const }
|
||||||
|
|
||||||
|
export const gekanatorKeys = {
|
||||||
|
root: ['gekanator'] as const,
|
||||||
|
posts: () => ['gekanator', 'posts'] as const,
|
||||||
|
questions: () => ['gekanator', 'questions'] as const,
|
||||||
|
extraQuestions: (gameId: number, nonce: string) =>
|
||||||
|
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
|
||||||
|
|
||||||
export const tagsKeys = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = {
|
|||||||
createdTo: '',
|
createdTo: '',
|
||||||
updatedFrom: '',
|
updatedFrom: '',
|
||||||
updatedTo: '',
|
updatedTo: '',
|
||||||
|
deprecated: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
order: 'updated_at:desc',
|
order: 'updated_at:desc',
|
||||||
@@ -57,6 +58,20 @@ describe ('tags API functions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each ([
|
||||||
|
[true, '1'],
|
||||||
|
[false, '0'],
|
||||||
|
] as const) ('maps deprecated=%s to %s', async (deprecated, expected) => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
|
||||||
|
|
||||||
|
await fetchTags ({ ...baseParams, deprecated })
|
||||||
|
|
||||||
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
|
'/tags',
|
||||||
|
{ params: expect.objectContaining ({ deprecated: expected }) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it ('returns null when tag fetches fail', async () => {
|
it ('returns null when tag fetches fail', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import type { Deerjikist,
|
|||||||
|
|
||||||
export const fetchTags = async (
|
export const fetchTags = async (
|
||||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams,
|
updatedFrom, updatedTo, deprecated,
|
||||||
|
page, limit, order }: FetchTagsParams,
|
||||||
): Promise<{ tags: Tag[]
|
): Promise<{ tags: Tag[]
|
||||||
count: number }> =>
|
count: number }> =>
|
||||||
await apiGet ('/tags', { params: {
|
await apiGet ('/tags', { params: {
|
||||||
@@ -23,6 +24,7 @@ export const fetchTags = async (
|
|||||||
...(createdTo && { created_to: createdTo }),
|
...(createdTo && { created_to: createdTo }),
|
||||||
...(updatedFrom && { updated_from: updatedFrom }),
|
...(updatedFrom && { updated_from: updatedFrom }),
|
||||||
...(updatedTo && { updated_to: updatedTo }),
|
...(updatedTo && { updated_to: updatedTo }),
|
||||||
|
...(deprecated != null && { deprecated: deprecated ? '1' : '0' }),
|
||||||
...(page && { page }),
|
...(page && { page }),
|
||||||
...(limit && { limit }),
|
...(limit && { limit }),
|
||||||
...(order && { order }) } })
|
...(order && { order }) } })
|
||||||
@@ -64,7 +66,6 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchTagChanges = async (
|
export const fetchTagChanges = async (
|
||||||
{ id, page, limit }: {
|
{ id, page, limit }: {
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GekanatorAnswerLog,
|
||||||
|
GekanatorAnswerValue,
|
||||||
|
GekanatorQuestion,
|
||||||
|
GekanatorQuestionCondition,
|
||||||
|
} from '@/lib/gekanator'
|
||||||
|
|
||||||
|
|
||||||
|
const question = (
|
||||||
|
condition: GekanatorQuestionCondition,
|
||||||
|
): GekanatorQuestion => ({
|
||||||
|
id: `${ condition.type }:candidate`,
|
||||||
|
text: 'candidate?',
|
||||||
|
kind: condition.type === 'source'
|
||||||
|
? 'source'
|
||||||
|
: condition.type.startsWith ('original-')
|
||||||
|
? 'original_date'
|
||||||
|
: condition.type.startsWith ('title-')
|
||||||
|
? 'title'
|
||||||
|
: 'tag',
|
||||||
|
condition,
|
||||||
|
source: 'default',
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: () => false,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const answer = (
|
||||||
|
condition: GekanatorQuestionCondition,
|
||||||
|
value: GekanatorAnswerValue,
|
||||||
|
): GekanatorAnswerLog => ({
|
||||||
|
questionId: 'previous',
|
||||||
|
questionText: 'previous?',
|
||||||
|
questionCondition: condition,
|
||||||
|
answer: value,
|
||||||
|
originalAnswer: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const blocked = (
|
||||||
|
candidate: GekanatorQuestionCondition,
|
||||||
|
previous: GekanatorQuestionCondition,
|
||||||
|
value: GekanatorAnswerValue,
|
||||||
|
): boolean =>
|
||||||
|
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
|
||||||
|
|
||||||
|
|
||||||
|
const gekanatorPageSource = readFileSync (
|
||||||
|
resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'),
|
||||||
|
'utf8')
|
||||||
|
|
||||||
|
const gekanatorBackdropSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
||||||
|
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
||||||
|
|
||||||
|
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const chooseQuestion'),
|
||||||
|
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
|
||||||
|
|
||||||
|
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
|
||||||
|
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
|
||||||
|
|
||||||
|
|
||||||
|
describe('GekanatorBackdrop regression structure', () => {
|
||||||
|
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderThumbnails')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading')
|
||||||
|
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"const renderedSettings = settingsForMode (displayedBackdropMode)")
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
'scaleForMode (displayedBackdropMode, displayedWinningRunCount)')
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"backdropMode === 'guess' || displayedBackdropMode === 'guess'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not split guess into a separate renderer or force a remount', () => {
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain (
|
||||||
|
'if (isGuessPresentation && guessThumbnail)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps tile keys independent from backdrop mode', () => {
|
||||||
|
expect(gekanatorBackdropSource).toContain ('key={duplicate}')
|
||||||
|
expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}')
|
||||||
|
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/)
|
||||||
|
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps guess on the shared scale, x, and y animation path', () => {
|
||||||
|
expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale')
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'")
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Gekanator question selection regression structure', () => {
|
||||||
|
it('prefers normal questions after user_suggested quota has been met', () => {
|
||||||
|
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
||||||
|
'else if (normalPool.length > 0)')
|
||||||
|
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
||||||
|
'else if (effectiveUserSuggestedPool.length > 0)')
|
||||||
|
|
||||||
|
expect(normalFallbackIndex).toBeGreaterThan(0)
|
||||||
|
expect(effectiveFallbackIndex).toBeGreaterThan(0)
|
||||||
|
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
|
||||||
|
expect(gekanatorFallbackQuestionSource).toContain (
|
||||||
|
"question.source !== 'user_suggested'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show a fixed extra-question count in the extra learning UI', () => {
|
||||||
|
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
|
||||||
|
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('isQuestionHardFilteredAfterAnswers', () => {
|
||||||
|
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
|
||||||
|
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'yes'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
|
||||||
|
.toBe(false)
|
||||||
|
expect(blocked ({ type: 'original-year', year: 2024 }, previous, 'yes')).toBe(false)
|
||||||
|
expect(blocked ({ type: 'source', host: 'example.com' }, previous, 'yes')).toBe(false)
|
||||||
|
expect(blocked ({ type: 'tag', key: 'character:喜多郁代' }, previous, 'yes')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks same-month facts after a no answer', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
|
||||||
|
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'no')).toBe(false)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'no'))
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks all month and month-day questions after a month-day yes answer', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = {
|
||||||
|
type: 'original-month-day',
|
||||||
|
monthDay: '12-25',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'yes'))
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks the same month-day only after a month-day no answer', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = {
|
||||||
|
type: 'original-month-day',
|
||||||
|
monthDay: '12-25',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'no'))
|
||||||
|
.toBe(false)
|
||||||
|
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks year and source as single-value facts', () => {
|
||||||
|
expect(blocked (
|
||||||
|
{ type: 'original-year', year: 2025 },
|
||||||
|
{ type: 'original-year', year: 2024 },
|
||||||
|
'yes',
|
||||||
|
)).toBe(true)
|
||||||
|
expect(blocked (
|
||||||
|
{ type: 'original-year', year: 2024 },
|
||||||
|
{ type: 'original-year', year: 2024 },
|
||||||
|
'no',
|
||||||
|
)).toBe(true)
|
||||||
|
expect(blocked (
|
||||||
|
{ type: 'source', host: 'b.example' },
|
||||||
|
{ type: 'source', host: 'a.example' },
|
||||||
|
'yes',
|
||||||
|
)).toBe(true)
|
||||||
|
expect(blocked (
|
||||||
|
{ type: 'source', host: 'b.example' },
|
||||||
|
{ type: 'source', host: 'a.example' },
|
||||||
|
'no',
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not hard-filter partial, probably_no, or unknown fact answers', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
const candidate: GekanatorQuestionCondition = { type: 'original-month', month: 2 }
|
||||||
|
|
||||||
|
expect(blocked (candidate, previous, 'partial')).toBe(false)
|
||||||
|
expect(blocked (candidate, previous, 'probably_no')).toBe(false)
|
||||||
|
expect(blocked (candidate, previous, 'unknown')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps title-length hard redundancy for yes and no only', () => {
|
||||||
|
const previous: GekanatorQuestionCondition = {
|
||||||
|
type: 'title-length-at-least',
|
||||||
|
length: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'yes'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'yes'))
|
||||||
|
.toBe(false)
|
||||||
|
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'no'))
|
||||||
|
.toBe(true)
|
||||||
|
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'no'))
|
||||||
|
.toBe(false)
|
||||||
|
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'partial'))
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
|
|||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
apiGet: vi.fn (),
|
apiGet: vi.fn (),
|
||||||
|
apiPatch: vi.fn (),
|
||||||
apiPut: vi.fn (),
|
apiPut: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
|
|||||||
const renderPage = () =>
|
const renderPage = () =>
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/materials/:id" element={<MaterialDetailPage/>}/>
|
<Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
|
||||||
</Routes>,
|
</Routes>,
|
||||||
{ route: '/materials/8' },
|
{ route: '/materials/8' },
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => {
|
|||||||
const textboxes = screen.getAllByRole ('textbox')
|
const textboxes = screen.getAllByRole ('textbox')
|
||||||
fireEvent.change (textboxes[0], { target: { value: 'new' } })
|
fireEvent.change (textboxes[0], { target: { value: 'new' } })
|
||||||
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
|
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
|
||||||
|
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
|
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
@@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => {
|
|||||||
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
|
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
|
||||||
expect (formData.get ('tag')).toBe ('new')
|
expect (formData.get ('tag')).toBe ('new')
|
||||||
expect (formData.get ('url')).toBe ('https://example.com/ref')
|
expect (formData.get ('url')).toBe ('https://example.com/ref')
|
||||||
|
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
|
||||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
|
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,22 +13,23 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet, apiPut } from '@/lib/api'
|
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { Material, Tag } from '@/types'
|
import type { Material, Tag, User } from '@/types'
|
||||||
|
|
||||||
type MaterialWithTag = Material & { tag: Tag }
|
type MaterialWithTag = Material & { tag: Tag }
|
||||||
|
|
||||||
type MaterialFormField = 'tag' | 'file' | 'url'
|
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
||||||
|
|
||||||
|
|
||||||
const MaterialDetailPage: FC = () => {
|
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
||||||
const { id } = useParams ()
|
const { id } = useParams ()
|
||||||
|
|
||||||
|
const [exportPath, setExportPath] = useState ('')
|
||||||
const [file, setFile] = useState<File | null> (null)
|
const [file, setFile] = useState<File | null> (null)
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
const [filePreview, setFilePreview] = useState ('')
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
@@ -49,6 +50,7 @@ const MaterialDetailPage: FC = () => {
|
|||||||
formData.append ('file', file)
|
formData.append ('file', file)
|
||||||
if (url.trim ())
|
if (url.trim ())
|
||||||
formData.append ('url', url)
|
formData.append ('url', url)
|
||||||
|
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -68,6 +70,30 @@ const MaterialDetailPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSuppress = async () => {
|
||||||
|
const reason = window.prompt ('抑止理由を入力してください。')
|
||||||
|
if (reason == null || reason.trim () === '')
|
||||||
|
return
|
||||||
|
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data = await apiPatch<Material> (
|
||||||
|
`/materials/${ id }/suppress_file`,
|
||||||
|
{ reason },
|
||||||
|
)
|
||||||
|
setMaterial (data)
|
||||||
|
setFile (null)
|
||||||
|
setFilePreview ('')
|
||||||
|
toast ({ title: '抑止しました' })
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toast ({ title: '抑止に失敗しました' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(id))
|
if (!(id))
|
||||||
return
|
return
|
||||||
@@ -82,11 +108,10 @@ const MaterialDetailPage: FC = () => {
|
|||||||
if (data.file && data.contentType)
|
if (data.file && data.contentType)
|
||||||
{
|
{
|
||||||
setFilePreview (data.file)
|
setFilePreview (data.file)
|
||||||
setFile (new File ([await (await fetch (data.file)).blob ()],
|
setFile (null)
|
||||||
data.file,
|
|
||||||
{ type: data.contentType }))
|
|
||||||
}
|
}
|
||||||
setURL (data.url ?? '')
|
setURL (data.url ?? '')
|
||||||
|
setExportPath (data.exportPaths.legacyDrive ?? '')
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -111,7 +136,14 @@ const MaterialDetailPage: FC = () => {
|
|||||||
withCount={false}/>
|
withCount={false}/>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
|
|
||||||
{(material.file && material.contentType) && (
|
{material.fileSuppressedAt && (
|
||||||
|
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3 text-red-700">
|
||||||
|
<span>素材ファイルは抑止済みです。</span>
|
||||||
|
{material.fileSuppressionReason && (
|
||||||
|
<span> 理由: {material.fileSuppressionReason}</span>)}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{(!material.fileSuppressedAt && material.file && material.contentType) && (
|
||||||
(/image\/.*/.test (material.contentType) && (
|
(/image\/.*/.test (material.contentType) && (
|
||||||
<img src={material.file} alt={material.tag.name || undefined}/>))
|
<img src={material.file} alt={material.tag.name || undefined}/>))
|
||||||
|| (/video\/.*/.test (material.contentType) && (
|
|| (/video\/.*/.test (material.contentType) && (
|
||||||
@@ -189,13 +221,36 @@ const MaterialDetailPage: FC = () => {
|
|||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="ZIP 出力パス"
|
||||||
|
messages={fieldErrors.exportPaths}>
|
||||||
|
{({ describedBy, invalid }) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={exportPath}
|
||||||
|
onChange={e => setExportPath (e.target.value)}
|
||||||
|
placeholder="伊地知ニジカ/表情/泣き.png"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
className={inputClass (invalid)}/>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||||
disabled={sending}>
|
disabled={sending}>
|
||||||
更新
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
|
{user?.role === 'admin' && !material.fileSuppressedAt && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSuppress}>
|
||||||
|
ファイルを抑止
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabGroup>
|
</TabGroup>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import SectionTitle from '@/components/common/SectionTitle'
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { API_BASE_URL, SITE_TITLE } from '@/config'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
@@ -30,10 +30,15 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
|
|||||||
to={`/materials/${ tag.material.id }`}
|
to={`/materials/${ tag.material.id }`}
|
||||||
className="block w-40 h-40">
|
className="block w-40 h-40">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full overflow-hidden rounded-xl shadow
|
className={`w-full h-full overflow-hidden rounded-xl shadow
|
||||||
text-center content-center text-4xl"
|
text-center content-center text-4xl ${
|
||||||
|
tag.material.fileSuppressedAt
|
||||||
|
? 'border-2 border-red-300 bg-red-50 text-base text-red-700'
|
||||||
|
: '' }`}
|
||||||
style={{ fontFamily: 'Nikumaru' }}>
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
|
{tag.material.fileSuppressedAt
|
||||||
|
? <span>抑止済み</span>
|
||||||
|
: (tag.material.contentType && /image\/.*/.test (tag.material.contentType))
|
||||||
? <img src={tag.material.file || undefined}/>
|
? <img src={tag.material.file || undefined}/>
|
||||||
: <span>照会</span>}
|
: <span>照会</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +113,7 @@ const MaterialListPage: FC = () => {
|
|||||||
|
|
||||||
<MaterialCard tag={tag}/>
|
<MaterialCard tag={tag}/>
|
||||||
|
|
||||||
<div className="ml-2">
|
<div className="ml-2 overflow-x-auto pb-2">
|
||||||
{tag.children.map (c2 => (
|
{tag.children.map (c2 => (
|
||||||
<Fragment key={c2.id}>
|
<Fragment key={c2.id}>
|
||||||
<SectionTitle>
|
<SectionTitle>
|
||||||
@@ -159,7 +164,11 @@ const MaterialListPage: FC = () => {
|
|||||||
<p>もしくは……</p>
|
<p>もしくは……</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
|
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
|
||||||
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
|
<li>
|
||||||
|
<a href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}>
|
||||||
|
すべての素材をダウンロードする
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>))}
|
</>))}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
type MaterialFormField = 'tag' | 'file' | 'url'
|
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
||||||
|
|
||||||
|
|
||||||
const MaterialNewPage: FC = () => {
|
const MaterialNewPage: FC = () => {
|
||||||
@@ -32,6 +32,7 @@ const MaterialNewPage: FC = () => {
|
|||||||
const [sending, setSending] = useState (false)
|
const [sending, setSending] = useState (false)
|
||||||
const [tag, setTag] = useState (tagQuery)
|
const [tag, setTag] = useState (tagQuery)
|
||||||
const [url, setURL] = useState ('')
|
const [url, setURL] = useState ('')
|
||||||
|
const [exportPath, setExportPath] = useState ('')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<MaterialFormField> ()
|
useValidationErrors<MaterialFormField> ()
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ const MaterialNewPage: FC = () => {
|
|||||||
formData.append ('file', file)
|
formData.append ('file', file)
|
||||||
if (url)
|
if (url)
|
||||||
formData.append ('url', url)
|
formData.append ('url', url)
|
||||||
|
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -133,6 +135,20 @@ const MaterialNewPage: FC = () => {
|
|||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="ZIP 出力パス"
|
||||||
|
messages={fieldErrors.exportPaths}>
|
||||||
|
{({ describedBy, invalid }) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={exportPath}
|
||||||
|
onChange={e => setExportPath (e.target.value)}
|
||||||
|
placeholder="伊地知ニジカ/表情/泣き.png"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
className={inputClass (invalid)}/>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import type { FC, FormEvent } from 'react'
|
|||||||
|
|
||||||
import type { Category, Tag } from '@/types'
|
import type { Category, Tag } from '@/types'
|
||||||
|
|
||||||
type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags'
|
type TagFormField =
|
||||||
|
| 'name'
|
||||||
|
| 'category'
|
||||||
|
| 'aliases'
|
||||||
|
| 'parentTags'
|
||||||
|
| 'deprecated'
|
||||||
|
|
||||||
|
|
||||||
const TagDetailPage: FC = () => {
|
const TagDetailPage: FC = () => {
|
||||||
@@ -35,6 +40,7 @@ const TagDetailPage: FC = () => {
|
|||||||
const [category, setCategory] = useState<Category> ('general')
|
const [category, setCategory] = useState<Category> ('general')
|
||||||
const [aliases, setAliases] = useState ('')
|
const [aliases, setAliases] = useState ('')
|
||||||
const [parentTags, setParentTags] = useState ('')
|
const [parentTags, setParentTags] = useState ('')
|
||||||
|
const [deprecated, setDeprecated] = useState (false)
|
||||||
const [disabled, setDisabled] = useState (true)
|
const [disabled, setDisabled] = useState (true)
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<TagFormField> ()
|
useValidationErrors<TagFormField> ()
|
||||||
@@ -50,6 +56,7 @@ const TagDetailPage: FC = () => {
|
|||||||
formData.append ('category', category)
|
formData.append ('category', category)
|
||||||
formData.append ('aliases', aliases)
|
formData.append ('aliases', aliases)
|
||||||
formData.append ('parent_tags', parentTags)
|
formData.append ('parent_tags', parentTags)
|
||||||
|
formData.append ('deprecated', deprecated ? '1' : '0')
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -59,6 +66,7 @@ const TagDetailPage: FC = () => {
|
|||||||
setCategory (data.category as Category)
|
setCategory (data.category as Category)
|
||||||
setAliases (data.aliases.join (' '))
|
setAliases (data.aliases.join (' '))
|
||||||
setParentTags (data.parents.map (t => t.name).join (' '))
|
setParentTags (data.parents.map (t => t.name).join (' '))
|
||||||
|
setDeprecated (Boolean (data.deprecatedAt))
|
||||||
|
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||||
@@ -82,6 +90,7 @@ const TagDetailPage: FC = () => {
|
|||||||
setCategory (tag.category as Category)
|
setCategory (tag.category as Category)
|
||||||
setAliases (tag.aliases.join (' '))
|
setAliases (tag.aliases.join (' '))
|
||||||
setParentTags (tag.parents.map (t => t.name).join (' '))
|
setParentTags (tag.parents.map (t => t.name).join (' '))
|
||||||
|
setDeprecated (Boolean (tag.deprecatedAt))
|
||||||
setDisabled (tag.category === 'nico')
|
setDisabled (tag.category === 'nico')
|
||||||
}, [tag])
|
}, [tag])
|
||||||
|
|
||||||
@@ -165,6 +174,17 @@ const TagDetailPage: FC = () => {
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="廃止済" messages={fieldErrors.deprecated}>
|
||||||
|
{({ describedBy, invalid }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={disabled}
|
||||||
|
checked={deprecated}
|
||||||
|
onChange={e => setDeprecated (e.target.checked)}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={invalid}/>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -20,15 +20,26 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
||||||
<>
|
<>
|
||||||
{(diff.prev && diff.prev !== diff.current) && (
|
{diff.prev !== diff.current
|
||||||
|
? (
|
||||||
<>
|
<>
|
||||||
<del className="text-red-600 dark:text-red-400">
|
<del className="text-red-600 dark:text-red-400">
|
||||||
{diff.prev}
|
{diff.prev && <>{diff.prev}<br/></>}
|
||||||
</del>
|
</del>
|
||||||
{diff.current && <br/>}
|
<ins className="text-green-600 dark:text-green-400">
|
||||||
</>)}
|
|
||||||
{diff.current}
|
{diff.current}
|
||||||
|
</ins>
|
||||||
</>)
|
</>)
|
||||||
|
: diff.current}
|
||||||
|
</>)
|
||||||
|
|
||||||
|
|
||||||
|
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
||||||
|
|
||||||
|
|
||||||
|
const renderStateDiff = (diff: { current: string | null; prev: string | null }) =>
|
||||||
|
renderDiff ({ current: tagStateLabel (diff.current),
|
||||||
|
prev: tagStateLabel (diff.prev) })
|
||||||
|
|
||||||
|
|
||||||
const TagHistoryPage: FC = () => {
|
const TagHistoryPage: FC = () => {
|
||||||
@@ -72,6 +83,8 @@ const TagHistoryPage: FC = () => {
|
|||||||
<col className="w-96"/>
|
<col className="w-96"/>
|
||||||
{/* カテゴリ */}
|
{/* カテゴリ */}
|
||||||
<col className="w-96"/>
|
<col className="w-96"/>
|
||||||
|
{/* 状態 */}
|
||||||
|
<col className="w-32"/>
|
||||||
{/* 別名 */}
|
{/* 別名 */}
|
||||||
<col className="w-[48rem]"/>
|
<col className="w-[48rem]"/>
|
||||||
{/* 上位タグ */}
|
{/* 上位タグ */}
|
||||||
@@ -87,6 +100,7 @@ const TagHistoryPage: FC = () => {
|
|||||||
<th className="p-2 text-left">版</th>
|
<th className="p-2 text-left">版</th>
|
||||||
<th className="p-2 text-left">名称</th>
|
<th className="p-2 text-left">名称</th>
|
||||||
<th className="p-2 text-left">カテゴリ</th>
|
<th className="p-2 text-left">カテゴリ</th>
|
||||||
|
<th className="p-2 text-left">状態</th>
|
||||||
<th className="p-2 text-left">別名</th>
|
<th className="p-2 text-left">別名</th>
|
||||||
<th className="p-2 text-left">上位タグ</th>
|
<th className="p-2 text-left">上位タグ</th>
|
||||||
<th className="p-2 text-left">更新日時</th>
|
<th className="p-2 text-left">更新日時</th>
|
||||||
@@ -106,6 +120,9 @@ const TagHistoryPage: FC = () => {
|
|||||||
prev: (change.category.prev
|
prev: (change.category.prev
|
||||||
&& CATEGORY_NAMES[change.category.prev]) })}
|
&& CATEGORY_NAMES[change.category.prev]) })}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-2 break-all">
|
||||||
|
{renderStateDiff (change.deprecatedAt)}
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{change.aliases.map ((tag, i) => (
|
{change.aliases.map ((tag, i) => (
|
||||||
tag.type === 'added'
|
tag.type === 'added'
|
||||||
@@ -178,6 +195,7 @@ const TagHistoryPage: FC = () => {
|
|||||||
`/tags/${ change.tagId }`,
|
`/tags/${ change.tagId }`,
|
||||||
{ name: change.name.current,
|
{ name: change.name.current,
|
||||||
category: change.category.current,
|
category: change.category.current,
|
||||||
|
deprecated: change.deprecatedAt.current ? '1' : '0',
|
||||||
aliases:
|
aliases:
|
||||||
change.aliases
|
change.aliases
|
||||||
.filter (t => t.type !== 'removed')
|
.filter (t => t.type !== 'removed')
|
||||||
@@ -211,4 +229,5 @@ const TagHistoryPage: FC = () => {
|
|||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default TagHistoryPage
|
export default TagHistoryPage
|
||||||
@@ -14,13 +14,22 @@ vi.mock ('@/lib/tags', () => tagsApi)
|
|||||||
describe ('TagListPage', () => {
|
describe ('TagListPage', () => {
|
||||||
it ('loads tags from URL filters and renders the results table', async () => {
|
it ('loads tags from URL filters and renders the results table', async () => {
|
||||||
tagsApi.fetchTags.mockResolvedValueOnce ({
|
tagsApi.fetchTags.mockResolvedValueOnce ({
|
||||||
tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })],
|
tags: [buildTag ({
|
||||||
|
id: 7,
|
||||||
|
name: '虹夏',
|
||||||
|
category: 'character',
|
||||||
|
postCount: 99,
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
})],
|
||||||
count: 1,
|
count: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagListPage/>,
|
<TagListPage/>,
|
||||||
{ route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' },
|
{
|
||||||
|
route:
|
||||||
|
'/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5&deprecated=1',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
@@ -30,6 +39,7 @@ describe ('TagListPage', () => {
|
|||||||
category: 'character',
|
category: 'character',
|
||||||
page: 3,
|
page: 3,
|
||||||
postCountGTE: 5,
|
postCountGTE: 5,
|
||||||
|
deprecated: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -38,6 +48,8 @@ describe ('TagListPage', () => {
|
|||||||
'/tags/7',
|
'/tags/7',
|
||||||
)
|
)
|
||||||
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
||||||
|
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
|
||||||
|
expect (screen.getAllByText ('廃止')).toHaveLength (2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('navigates to a normalized search URL on submit', async () => {
|
it ('navigates to a normalized search URL on submit', async () => {
|
||||||
@@ -46,7 +58,9 @@ describe ('TagListPage', () => {
|
|||||||
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
||||||
fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } })
|
fireEvent.change (screen.getAllByRole ('combobox')[0], {
|
||||||
|
target: { value: 'character' },
|
||||||
|
})
|
||||||
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const boolFromQuery = (value: string | null): boolean =>
|
||||||
|
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||||
|
|
||||||
|
|
||||||
|
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
||||||
|
|
||||||
|
|
||||||
const TagListPage: FC = () => {
|
const TagListPage: FC = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
@@ -48,6 +55,9 @@ const TagListPage: FC = () => {
|
|||||||
const qCreatedTo = query.get ('created_to') ?? ''
|
const qCreatedTo = query.get ('created_to') ?? ''
|
||||||
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
|
const qDeprecated = query.has ('deprecated')
|
||||||
|
? boolFromQuery (query.get ('deprecated'))
|
||||||
|
: null
|
||||||
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
|
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
|
||||||
|
|
||||||
const [name, setName] = useState ('')
|
const [name, setName] = useState ('')
|
||||||
@@ -58,6 +68,7 @@ const TagListPage: FC = () => {
|
|||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||||
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
||||||
|
const [deprecated, setDeprecated] = useState<boolean | null> (null)
|
||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
page, limit, order,
|
page, limit, order,
|
||||||
@@ -69,7 +80,8 @@ const TagListPage: FC = () => {
|
|||||||
createdFrom: qCreatedFrom,
|
createdFrom: qCreatedFrom,
|
||||||
createdTo: qCreatedTo,
|
createdTo: qCreatedTo,
|
||||||
updatedFrom: qUpdatedFrom,
|
updatedFrom: qUpdatedFrom,
|
||||||
updatedTo: qUpdatedTo }
|
updatedTo: qUpdatedTo,
|
||||||
|
deprecated: qDeprecated }
|
||||||
const { data, isLoading: loading } = useQuery ({
|
const { data, isLoading: loading } = useQuery ({
|
||||||
queryKey: tagsKeys.index (keys),
|
queryKey: tagsKeys.index (keys),
|
||||||
queryFn: () => fetchTags (keys) })
|
queryFn: () => fetchTags (keys) })
|
||||||
@@ -85,10 +97,11 @@ const TagListPage: FC = () => {
|
|||||||
setCreatedTo (qCreatedTo)
|
setCreatedTo (qCreatedTo)
|
||||||
setUpdatedFrom (qUpdatedFrom)
|
setUpdatedFrom (qUpdatedFrom)
|
||||||
setUpdatedTo (qUpdatedTo)
|
setUpdatedTo (qUpdatedTo)
|
||||||
|
setDeprecated (qDeprecated)
|
||||||
|
|
||||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||||
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
|
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
|
||||||
qPostCountLTE, qUpdatedFrom, qUpdatedTo])
|
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
@@ -104,6 +117,8 @@ const TagListPage: FC = () => {
|
|||||||
setIf (qs, 'created_to', createdTo)
|
setIf (qs, 'created_to', createdTo)
|
||||||
setIf (qs, 'updated_from', updatedFrom)
|
setIf (qs, 'updated_from', updatedFrom)
|
||||||
setIf (qs, 'updated_to', updatedTo)
|
setIf (qs, 'updated_to', updatedTo)
|
||||||
|
if (deprecated != null)
|
||||||
|
qs.set ('deprecated', deprecated ? '1' : '0')
|
||||||
qs.set ('page', '1')
|
qs.set ('page', '1')
|
||||||
qs.set ('order', order)
|
qs.set ('order', order)
|
||||||
|
|
||||||
@@ -201,6 +216,21 @@ const TagListPage: FC = () => {
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="状態">
|
||||||
|
{({ invalid }) => (
|
||||||
|
<select
|
||||||
|
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
|
||||||
|
onChange={e => setDeprecated (
|
||||||
|
e.target.value === ''
|
||||||
|
? null
|
||||||
|
: e.target.value === '1')}
|
||||||
|
className={inputClass (invalid)}>
|
||||||
|
<option value=""> </option>
|
||||||
|
<option value="0">有効</option>
|
||||||
|
<option value="1">廃止</option>
|
||||||
|
</select>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -219,6 +249,7 @@ const TagListPage: FC = () => {
|
|||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
<col className="w-16"/>
|
<col className="w-16"/>
|
||||||
<col className="w-48"/>
|
<col className="w-48"/>
|
||||||
|
<col className="w-32"/>
|
||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
<col className="w-48"/>
|
<col className="w-48"/>
|
||||||
<col className="w-56"/>
|
<col className="w-56"/>
|
||||||
@@ -249,6 +280,7 @@ const TagListPage: FC = () => {
|
|||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
|
<th className="p-2 text-left whitespace-nowrap">状態</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">別名</th>
|
<th className="p-2 text-left whitespace-nowrap">別名</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
|
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
@@ -280,6 +312,7 @@ const TagListPage: FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-right">{row.postCount}</td>
|
<td className="p-2 text-right">{row.postCount}</td>
|
||||||
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
||||||
|
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
|
||||||
<td className="p-2">{row.aliases.join (' ')}</td>
|
<td className="p-2">{row.aliases.join (' ')}</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{row.parents.map (t => (
|
{row.parents.map (t => (
|
||||||
|
|||||||
変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示
新しい課題から参照
ユーザをブロックする