コミットを比較

..

11 コミット

作成者 SHA1 メッセージ 日付
みてるぞ c2102c8f96 #306 2026-06-24 01:26:26 +09:00
みてるぞ 510cbb0d78 #306 2026-06-24 00:38:29 +09:00
みてるぞ a820ce4c3e #306 事故が起きたので,エージェントへの指示を追加 2026-06-23 23:43:01 +09:00
みてるぞ 507ce1680e #306 2026-06-23 22:05:11 +09:00
みてるぞ ec2b3d2254 タグ “廃止” 追加 (#378) (#379)
Reviewed-on: #379
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-22 08:40:06 +09:00
みてるぞ ffd28c0f9e グカネータ スコア補正 (#376) (#377)
Reviewed-on: #377
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-18 01:15:54 +09:00
みてるぞ a54ca72244 グカネータ改良 (#371) (#375)
Reviewed-on: #375
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-17 01:04:57 +09:00
みてるぞ 5bbd6eda11 グカネータ軽量モード廃止 (#370) (#372)
Reviewed-on: #372
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-15 22:14:08 +09:00
みてるぞ ece95838f0 グカネータ公開 / 洗澡鹿のパス変更 (#361) (#369)
Reviewed-on: #369
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-14 05:40:31 +09:00
みてるぞ 7ab46f907f グカネータ公開 (#361) (#368)
Reviewed-on: #368
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-14 05:33:39 +09:00
みてるぞ e94720941c グカネータ作成 / ウィニング・ラン修正 (#41) (#366)
Reviewed-on: #366
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-12 02:08:59 +09:00
93個のファイルの変更7822行の追加1070行の削除
+83
ファイルの表示
@@ -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,6 +130,64 @@ 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 - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working and rerun them until
@@ -134,6 +197,16 @@ npm run preview
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before
editing backend behavior. editing backend behavior.
- 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 - For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests. `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
@@ -158,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
@@ -179,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:
+6
ファイルの表示
@@ -72,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
+146 -14
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController class GekanatorGamesController < ApplicationController
def create def create
return head :not_found unless current_user&.admin? return head :unauthorized unless current_user
guessed_post_id = params.require(:guessed_post_id) guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence correct_post_id = params[:correct_post_id].presence
@@ -14,18 +14,29 @@ class GekanatorGamesController < ApplicationController
question_count: answers.length, question_count: answers.length,
answers:) answers:)
if game.save if game.invalid?
render json: { id: game.id }, status: :created
else
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
return
end 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 end
def extra_questions def extra_questions
return head :not_found unless current_user&.admin? game = find_owned_game
return if performed?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
questions = questions =
GekanatorQuestion GekanatorQuestion
@@ -34,10 +45,12 @@ class GekanatorGamesController < ApplicationController
.where(kind: 'post_similarity', source: 'user_suggested') .where(kind: 'post_similarity', source: 'user_suggested')
.to_a .to_a
selected = weighted_sample_questions( selected =
prioritized_extra_questions(
questions, questions,
post_id: game.correct_post_id, post_id: game.correct_post_id,
limit: 2) user: current_user,
limit: 6)
render json: { render json: {
questions: selected.map { |question| extra_question_json(question) } questions: selected.map { |question| extra_question_json(question) }
@@ -45,10 +58,8 @@ class GekanatorGamesController < ApplicationController
end end
def extra_question_answers def extra_question_answers
return head :not_found unless current_user&.admin? game = find_owned_game
return if performed?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
answer_params = params.require(:answers) answer_params = params.require(:answers)
if !answer_params.is_a?(Array) if !answer_params.is_a?(Array)
@@ -100,6 +111,23 @@ class GekanatorGamesController < ApplicationController
} }
end 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: def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id) remaining = questions.uniq(&:id)
selected = [] selected = []
@@ -137,4 +165,108 @@ class GekanatorGamesController < ApplicationController
question.priority_weight.to_f / (1.0 + sample_count * 0.15) question.priority_weight.to_f / (1.0 + sample_count * 0.15)
end 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 end
+31 -6
ファイルの表示
@@ -1,21 +1,30 @@
class GekanatorPostsController < ApplicationController class GekanatorPostsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
posts = posts =
Post Post
.preload(tags: :tag_name) .preload(:post_similarities, tags: :tag_name)
.with_attached_thumbnail .with_attached_thumbnail
.order(Arel.sql( .order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
'posts.original_created_from, posts.created_at) DESC, posts.id DESC')) 'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
.to_a
render json: { posts: posts.map { |post| post_json(post) } } 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 end
private private
def post_json post def post_json post, active_tags_by_post_id:
{ {
id: post.id, id: post.id,
url: post.url, url: post.url,
@@ -24,10 +33,26 @@ class GekanatorPostsController < ApplicationController
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
tags: post.tags.map { |tag| tag_json(tag) } 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 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 def tag_json tag
{ {
id: tag.id, id: tag.id,
+43 -1
ファイルの表示
@@ -1,9 +1,41 @@
class GekanatorQuestionSuggestionsController < ApplicationController class GekanatorQuestionSuggestionsController < ApplicationController
def create def create
return head :not_found unless current_user&.admin? return head :unauthorized unless current_user
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id)) game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
return head :not_found unless game 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( suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game, gekanator_game: game,
@@ -50,4 +82,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController
rescue NotImplementedError rescue NotImplementedError
head :not_implemented head :not_implemented
end 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 end
+51 -4
ファイルの表示
@@ -1,15 +1,20 @@
class GekanatorQuestionsController < ApplicationController class GekanatorQuestionsController < ApplicationController
def index def index
return head :not_found unless current_user&.admin?
questions = questions =
GekanatorQuestion GekanatorQuestion
.accepted .accepted
.includes(:gekanator_question_examples) .includes(:gekanator_question_examples)
.order(priority_weight: :desc, id: :asc) .order(priority_weight: :desc, id: :asc)
.to_a
deprecated_tag_keys = deprecated_tag_keys_for(questions)
render json: { render json: {
questions: questions.map { |question| question_json(question) } questions: questions.filter_map { |question|
json = question_json(question)
next if hidden_question?(json[:condition], deprecated_tag_keys)
json
}
} }
end end
@@ -18,6 +23,7 @@ class GekanatorQuestionsController < ApplicationController
def question_json question def question_json question
condition = condition_json(question.condition).deep_symbolize_keys condition = condition_json(question.condition).deep_symbolize_keys
json = { json = {
record_id: question.id,
id: question_id_for(question, condition), id: question_id_for(question, condition),
text: question_text_for(question, condition), text: question_text_for(question, condition),
kind: question.kind, kind: question.kind,
@@ -25,7 +31,7 @@ class GekanatorQuestionsController < ApplicationController
source: question.source, source: question.source,
priority_weight: question.priority_weight priority_weight: question.priority_weight
} }
if question.kind == 'post_similarity' if question.kind == 'post_similarity' || question.kind == 'tag'
json[:example_answers] = example_answers_json(question) json[:example_answers] = example_answers_json(question)
end end
json json
@@ -49,6 +55,8 @@ class GekanatorQuestionsController < ApplicationController
"title:length-at-least:#{ condition[:length].to_i + 1 }" "title:length-at-least:#{ condition[:length].to_i + 1 }"
when 'title-has-ascii' when 'title-has-ascii'
'title:ascii' 'title:ascii'
when 'title-contains'
"title:contains:#{ condition[:text] }"
when 'post-similarity' when 'post-similarity'
"post-similarity:#{ question.id }" "post-similarity:#{ question.id }"
else else
@@ -77,6 +85,8 @@ class GekanatorQuestionsController < ApplicationController
case condition[:type] case condition[:type]
when 'title-length-at-least' when 'title-length-at-least'
"タイトルは #{ condition[:length] } 文字以上?" "タイトルは #{ condition[:length] } 文字以上?"
when 'title-contains'
"題名に「#{ condition[:text] }」が含まれる?"
else else
question.text question.text
end end
@@ -97,4 +107,41 @@ class GekanatorQuestionsController < ApplicationController
.first .first
&.first &.first
end 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 end
+191 -16
ファイルの表示
@@ -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
+15 -8
ファイルの表示
@@ -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])
+3
ファイルの表示
@@ -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,
+128 -31
ファイルの表示
@@ -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
+11 -7
ファイルの表示
@@ -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,
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
class GekanatorQuestionExample < ApplicationRecord class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown'] NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze
belongs_to :gekanator_question belongs_to :gekanator_question
belongs_to :post belongs_to :post
@@ -35,7 +35,7 @@ class GekanatorQuestionExample < ApplicationRecord
self.answer_counts = counts self.answer_counts = counts
self.sample_count = counts.values.sum self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present? self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source if new_record? self.source = source
apply_aggregated_answer!(preferred_answer: answer) apply_aggregated_answer!(preferred_answer: answer)
self self
-13
ファイルの表示
@@ -1,5 +1,4 @@
class GekanatorQuestionSuggestion < ApplicationRecord class GekanatorQuestionSuggestion < ApplicationRecord
MAX_QUESTIONS_PER_GAME = 3
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game belongs_to :gekanator_game
@@ -10,16 +9,4 @@ class GekanatorQuestionSuggestion < ApplicationRecord
validates :question_text, presence: true, length: { maximum: 1000 } validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS } validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] } validates :processed, inclusion: { in: [true, false] }
validate :question_suggestion_limit_per_game, on: :create
private
def question_suggestion_limit_per_game
return if gekanator_game_id.blank?
count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count
if count >= MAX_QUESTIONS_PER_GAME
errors.add(:base, '質問追加数を超えてゐます.')
end
end
end end
+11
ファイルの表示
@@ -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
+48
ファイルの表示
@@ -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
+29
ファイルの表示
@@ -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
+18
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -7,6 +7,8 @@ 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
+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
+12 -4
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -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
+21 -3
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@
module WikiPageRepr module WikiPageRepr
BASE = { methods: [:title] }.freeze BASE = { methods: [:title, :deprecated_at] }.freeze
module_function module_function
+151 -1
ファイルの表示
@@ -1,5 +1,15 @@
module Gekanator module Gekanator
class QuestionSuggestionAiConverter 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 self.call(...) = new(...).call
def initialize suggestion:, user: def initialize suggestion:, user:
@@ -8,11 +18,151 @@ module Gekanator
end end
def call def call
raise NotImplementedError, 'AI question conversion is not implemented yet.' 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 end
private private
attr_reader :suggestion, :user 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
end end
+34
ファイルの表示
@@ -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
+7
ファイルの表示
@@ -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
+70
ファイルの表示
@@ -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
+149
ファイルの表示
@@ -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
+3 -2
ファイルの表示
@@ -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 = { }
+1
ファイルの表示
@@ -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
+2 -1
ファイルの表示
@@ -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
+6 -1
ファイルの表示
@@ -113,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
+20
ファイルの表示
@@ -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
+98
ファイルの表示
@@ -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
生成ファイル
+54 -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_12_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
@@ -130,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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
@@ -141,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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|
@@ -161,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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"
@@ -319,6 +364,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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
@@ -336,10 +382,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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
@@ -563,6 +612,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id" add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "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"
@@ -571,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_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 -1
ファイルの表示
@@ -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 -1
ファイルの表示
@@ -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
+57
ファイルの表示
@@ -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
+4
ファイルの表示
@@ -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: '')
+73
ファイルの表示
@@ -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) }
+5 -4
ファイルの表示
@@ -52,16 +52,16 @@ RSpec.describe 'Gekanator games API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns not found without an admin user' do it 'returns unauthorized without a user' do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
answers: [] } answers: [] }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:unauthorized)
end end
it 'returns not found for a non-admin user' do it 'stores a game for a non-admin user' do
sign_in_as user sign_in_as user
post '/gekanator/games', params: { post '/gekanator/games', params: {
@@ -69,7 +69,8 @@ RSpec.describe 'Gekanator games API', type: :request do
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
answers: [{ question_id: 'tag:1', answer: 'yes' }] } answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).user).to eq(user)
end end
end end
end end
+464 -14
ファイルの表示
@@ -129,7 +129,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns not found for a non-admin user' do it 'stores a game result for a non-admin user' do
sign_in_as member sign_in_as member
post '/gekanator/games', params: { post '/gekanator/games', params: {
@@ -138,7 +138,200 @@ RSpec.describe 'Gekanator learning API', type: :request do
answers: [] answers: []
} }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).user).to eq(member)
end
it 'returns unauthorized without a user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: []
}
expect(response).to have_http_status(:unauthorized)
end
it 'learns accepted non-nico tag answers from camelCase main game logs' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: 'MAD 要素がある?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'meme:MAD' },
created_by: admin
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:missing',
question_text: '存在しない質問?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'unknown',
original_answer: 'unknown'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: tag_question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'yes',
source: 'post_game_answer'
)
expect(example.gekanator_game_id).to eq(json['id'])
end
it 'learns accepted post_similarity answers from main game logs' do
sign_in_as admin
question = create_post_similarity_question!(text: '泣いてる?')
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: "post-similarity:#{question.id}",
question_text: '泣いてる?',
answer: 'partial',
original_answer: 'partial'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'partial',
source: 'post_game_answer'
)
expect(example.gekanator_game_id).to eq(json['id'])
end
it 'does not learn fact questions or nico tag questions from main game logs' do
sign_in_as admin
[
{
text: 'example.com 由来?',
kind: 'source',
condition: { type: 'source', host: 'example.com' }
},
{
text: '題名に結束バンドを含む?',
kind: 'title',
condition: { type: 'title-contains', text: '結束バンド' }
},
{
text: '2024 年投稿?',
kind: 'original_date',
condition: { type: 'original-year', year: 2024 }
},
{
text: 'ニコニコにぼっちタグ?',
kind: 'tag',
condition: { type: 'tag', key: 'nico:ぼっち' }
}
].each do |attributes|
GekanatorQuestion.create!(
text: attributes[:text],
kind: attributes[:kind],
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: attributes[:condition],
created_by: admin
)
end
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{ question_id: 'source:example.com', answer: 'yes' },
{ question_id: 'title:contains:結束バンド', answer: 'yes' },
{ question_id: 'original-year:2024', answer: 'yes' },
{ question_id: 'tag:nico:ぼっち', answer: 'yes' }
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(0)
end
it 'updates an existing main game example instead of duplicating it' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: '喜多ちゃんが関係してる?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'character:喜多郁代' },
created_by: admin
)
existing = GekanatorQuestionExample.create!(
gekanator_question: tag_question,
post: correct_post,
user: admin,
answer: 'no',
source: 'post_game_answer',
weight: 1.0
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: 'tag:character:喜多郁代',
answer: 'yes',
original_answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
expect(existing.reload.answer).to eq('yes')
expect(existing.sample_count).to eq(2)
end end
end end
@@ -238,7 +431,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(GekanatorQuestionSuggestion.last.processed).to eq(false) expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
end end
it 'limits suggestions to three per game' do it 'allows more than three suggestions per game' do
sign_in_as admin sign_in_as admin
3.times do |i| 3.times do |i|
@@ -256,47 +449,119 @@ RSpec.describe 'Gekanator learning API', type: :request do
question_text: 'fourth question?', question_text: 'fourth question?',
answer: 'yes' answer: 'yes'
} }
}.not_to change { GekanatorQuestionSuggestion.count } }.to change { GekanatorQuestionSuggestion.count }.by(1)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:created)
expect(json['count']).to eq(4)
end end
it 'returns not found for a non-admin user' do it 'allows a non-admin user to suggest a question for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
sign_in_as member sign_in_as member
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: member_game.id,
question_text: 'member question?',
answer: 'yes'
}
}.to change { GekanatorQuestionSuggestion.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionSuggestion.last).to have_attributes(
gekanator_game_id: member_game.id,
user_id: member.id
)
end
it 'returns not found for another user game' do
sign_in_as member
expect {
post '/gekanator/question_suggestions', params: { post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id, gekanator_game_id: game.id,
question_text: 'member question?', question_text: 'member question?',
answer: 'yes' answer: 'yes'
} }
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns unauthorized without a user' do
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'GET /gekanator/games/:id/extra_questions' do describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin sign_in_as admin
lowest = create_post_similarity_question!(
text: 'lowest?',
priority_weight: 0.5
)
low = create_post_similarity_question!( low = create_post_similarity_question!(
text: 'low?', text: 'low?',
priority_weight: 1.0 priority_weight: 1.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 3.0
)
middle = create_post_similarity_question!( middle = create_post_similarity_question!(
text: 'middle?', text: 'middle?',
priority_weight: 1.5
)
medium_high = create_post_similarity_question!(
text: 'medium high?',
priority_weight: 2.0 priority_weight: 2.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 2.5
)
higher = create_post_similarity_question!(
text: 'higher?',
priority_weight: 2.8
)
highest = create_post_similarity_question!(
text: 'highest?',
priority_weight: 3.0
)
overflow = create_post_similarity_question!(
text: 'overflow?',
priority_weight: 2.2
)
get "/gekanator/games/#{game.id}/extra_questions" get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2) expect(json['questions'].length).to eq(6)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2) expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id])) expect(json['questions'].map { _1['id'] }).to all(
be_in([
lowest.id,
low.id,
middle.id,
medium_high.id,
high.id,
higher.id,
highest.id,
overflow.id,
])
)
end end
it 'can return questions that already have an example for the correct post' do it 'can return questions that already have an example for the correct post' do
@@ -319,6 +584,37 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(json['questions'].map { _1['id'] }).to include(existing.id) expect(json['questions'].map { _1['id'] }).to include(existing.id)
end end
it 'prioritizes questions the current user has not answered' do
sign_in_as admin
answered = create_post_similarity_question!(
text: 'already answered?',
priority_weight: 3.0
)
GekanatorQuestionExample.create!(
gekanator_question: answered,
post: other_post,
user: admin,
answer: 'yes',
source: 'post_game_extra'
)
unanswered =
6.times.map { |index|
create_post_similarity_question!(
text: "unanswered #{index}?",
priority_weight: 0.5
)
}
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to match_array(
unanswered.map(&:id)
)
end
it 'can return questions already asked in the game using snake_case question_id' do it 'can return questions already asked in the game using snake_case question_id' do
sign_in_as admin sign_in_as admin
@@ -377,6 +673,38 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id)
end end
it 'allows a non-admin user to fetch extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
accepted = create_post_similarity_question!(text: 'accepted?')
sign_in_as member
get "/gekanator/games/#{member_game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(accepted.id)
end
it 'returns not found for another user game' do
sign_in_as member
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'POST /gekanator/games/:id/extra_question_answers' do describe 'POST /gekanator/games/:id/extra_question_answers' do
@@ -503,9 +831,103 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'allows a non-admin user to answer extra questions for their own game' do
member_game = GekanatorGame.create!(
user: member,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
)
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{member_game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionExample.last).to have_attributes(
user_id: member.id,
gekanator_game_id: member_game.id
)
end
it 'returns not found for another user game' do
question = create_post_similarity_question!(text: 'extra?')
sign_in_as member
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:not_found)
end
it 'returns unauthorized without a user' do
question = create_post_similarity_question!(text: 'extra?')
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
expect(response).to have_http_status(:unauthorized)
end
end end
describe 'GET /gekanator/questions' do describe 'GET /gekanator/questions' do
it 'omits questions for deprecated tags' do
active_tag = Tag.create!(name: 'active_question_tag', category: :general)
deprecated_tag = Tag.create!(
name: 'deprecated_question_tag',
category: :general,
deprecated_at: Time.current
)
[active_tag, deprecated_tag].each do |question_tag|
GekanatorQuestion.create!(
text: "#{ question_tag.name }?",
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'tag',
key: "#{ question_tag.category }:#{ question_tag.name }"
},
created_by: admin
)
end
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_ids = json.fetch('questions').map { |question| question.fetch('id') }
expect(question_ids).to include('tag:general:active_question_tag')
expect(question_ids).not_to include('tag:general:deprecated_question_tag')
end
it 'returns accepted questions only and includes example_answers for post_similarity questions' do it 'returns accepted questions only and includes example_answers for post_similarity questions' do
sign_in_as admin sign_in_as admin
@@ -608,5 +1030,33 @@ RSpec.describe 'Gekanator learning API', type: :request do
'length' => 21 'length' => 21
) )
end end
it 'returns title-contains questions without authentication' do
GekanatorQuestion.create!(
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'ai_generated',
status: 'accepted',
priority_weight: 0.95,
condition: {
type: 'title-contains',
text: '結束バンド'
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' }
expect(question_json).to include(
'text' => '題名に「結束バンド」が含まれる?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-contains',
'text' => '結束バンド'
)
end
end end
end end
+33
ファイルの表示
@@ -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
+278 -21
ファイルの表示
@@ -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
+90
ファイルの表示
@@ -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!(
+11
ファイルの表示
@@ -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' }
+3
ファイルの表示
@@ -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)
+193
ファイルの表示
@@ -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)
+17 -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
) )
+112
ファイルの表示
@@ -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
+5 -2
ファイルの表示
@@ -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
+5 -2
ファイルの表示
@@ -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
バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 559 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 146 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 1.2 MiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 188 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 201 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 196 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 179 KiB

+3 -16
ファイルの表示
@@ -40,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
@@ -69,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/>}/>
@@ -81,10 +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={ <Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<AdminOnly user={user}>
<GekanatorPage/>
</AdminOnly>}/>
<Route path="/more" element={<MorePage/>}/> <Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/> <Route path="*" element={<NotFound/>}/>
</Routes> </Routes>
@@ -92,16 +89,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
} }
const AdminOnly = ({ user, children }: {
user: User | null
children: ReactNode }) => {
if (user?.role !== 'admin')
return <NotFound/>
return <>{children}</>
}
const PostDetailRoute = ({ user }: { user: User | null }) => { const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation () const location = useLocation ()
const key = location.pathname const key = location.pathname
+15
ファイルの表示
@@ -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' })}/>,
+2 -1
ファイルの表示
@@ -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 },
+150 -2
ファイルの表示
@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import { import {
buildGekanatorQuestions, buildGekanatorQuestions,
expectedAnswerForQuestion, expectedAnswerForQuestion,
fetchGekanatorPosts,
fetchGekanatorQuestions,
learnedSemanticSideForPost,
questionIdForCondition,
restoreGekanatorQuestion, restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers, saveGekanatorExtraQuestionAnswers,
saveGekanatorGame, saveGekanatorGame,
@@ -22,6 +26,7 @@ vi.mock('@/lib/api', () => ({
})) }))
const mockedApiPost = vi.mocked(apiPost) const mockedApiPost = vi.mocked(apiPost)
const mockedApiGet = vi.mocked(apiGet)
const post = (overrides: Partial<Post> = {}): Post => ({ const post = (overrides: Partial<Post> = {}): Post => ({
id: 1, id: 1,
@@ -41,6 +46,24 @@ const post = (overrides: Partial<Post> = {}): Post => ({
...overrides, ...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', () => { describe('expectedAnswerForQuestion', () => {
it('returns a direct example answer when present', () => { it('returns a direct example answer when present', () => {
const question: StoredGekanatorQuestion = { const question: StoredGekanatorQuestion = {
@@ -124,6 +147,7 @@ describe('expectedAnswerForQuestion', () => {
postCount: 1, postCount: 1,
createdAt: '2026-06-10T00:00:00.000Z', createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z',
deprecatedAt: null,
hasWiki: false, hasWiki: false,
hasDeerjikists: false, hasDeerjikists: false,
materialId: null, materialId: null,
@@ -164,6 +188,54 @@ describe('expectedAnswerForQuestion', () => {
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no') 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', () => { describe('restoreGekanatorQuestion', () => {
@@ -226,7 +298,7 @@ describe('restoreGekanatorQuestion', () => {
}) })
expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false) expect(question.test(post({ id: 2 }))).toBe(true)
}) })
it('normalizes legacy title-length-greater-than questions', () => { it('normalizes legacy title-length-greater-than questions', () => {
@@ -248,6 +320,21 @@ describe('restoreGekanatorQuestion', () => {
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false) expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true) 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', () => { describe('buildGekanatorQuestions', () => {
@@ -264,6 +351,59 @@ describe('buildGekanatorQuestions', () => {
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/) expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\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', () => { describe('Gekanator API writers', () => {
@@ -282,6 +422,10 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
questionMode: 'normal',
questionPurpose: 'effective_user_suggested',
effectiveQuestion: true,
learningQuestion: false,
answer: 'yes', answer: 'yes',
originalAnswer: 'partial', originalAnswer: 'partial',
}, },
@@ -306,6 +450,10 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
question_mode: 'normal',
question_purpose: 'effective_user_suggested',
effective_question: true,
learning_question: false,
answer: 'yes', answer: 'yes',
original_answer: 'partial', original_answer: 'partial',
}, },
+131 -20
ファイルの表示
@@ -9,10 +9,24 @@ export type GekanatorAnswerValue =
| 'probably_no' | 'probably_no'
| 'unknown' | 'unknown'
export type LearnedSemanticSide =
| 'positive'
| 'negative'
| 'unknown'
export type GekanatorQuestionPurpose =
| 'effective_user_suggested'
| 'learning_user_suggested'
| 'normal'
export type GekanatorAnswerLog = { export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
questionCondition?: GekanatorQuestionCondition questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run'
questionPurpose?: GekanatorQuestionPurpose
effectiveQuestion?: boolean
learningQuestion?: boolean
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue } originalAnswer: GekanatorAnswerValue }
@@ -29,6 +43,8 @@ export type GekanatorQuestionSource =
| 'ai_generated' | 'ai_generated'
| 'admin_curated' | 'admin_curated'
export type GekanatorPerformanceMode = 'normal'
export type GekanatorQuestionCondition = export type GekanatorQuestionCondition =
| { type: 'tag'; key: string } | { type: 'tag'; key: string }
| { type: 'source'; host: string } | { type: 'source'; host: string }
@@ -38,6 +54,7 @@ export type GekanatorQuestionCondition =
| { type: 'title-length-at-least'; length: number } | { type: 'title-length-at-least'; length: number }
| { type: 'title-length-greater-than'; length: number } | { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' } | { type: 'title-has-ascii' }
| { type: 'title-contains'; text: string }
| { | {
type: 'post-similarity' type: 'post-similarity'
postId: number postId: number
@@ -58,6 +75,7 @@ export type GekanatorExtraQuestion = {
priorityWeight: number } priorityWeight: number }
export type StoredGekanatorQuestion = { export type StoredGekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -67,6 +85,7 @@ export type StoredGekanatorQuestion = {
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = { export type GekanatorQuestion = {
recordId?: number
id: string id: string
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
@@ -76,6 +95,13 @@ export type GekanatorQuestion = {
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean } test: (post: Post) => boolean }
export type BuildGekanatorQuestionsOptions = {
includeTitleContains?: boolean
tagQuestionCap?: number
titleContainsCap?: number
totalQuestionCap?: number
}
export const normalizeTitleLengthCondition = ( export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition,
@@ -127,6 +153,8 @@ export const questionIdForCondition = (
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }` return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
case 'title-has-ascii': case 'title-has-ascii':
return 'title:ascii' return 'title:ascii'
case 'title-contains':
return `title:contains:${ condition.text }`
} }
} }
@@ -135,7 +163,7 @@ const directExampleAnswerFor = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
post: Post, post: Post,
): GekanatorAnswerValue | null => { ): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity') if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null return null
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
@@ -148,6 +176,26 @@ const directExampleAnswerFor = (
return null 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 countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> () const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
@@ -270,8 +318,8 @@ const questionMatches = (
): boolean => { ): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.condition.type === 'post-similarity' return question.kind === 'post_similarity'
? directAnswer === question.condition.answer ? learnedSemanticSideForAnswer (directAnswer) === 'positive'
: directAnswer === 'yes' : directAnswer === 'yes'
switch (question.condition.type) switch (question.condition.type)
@@ -292,6 +340,8 @@ const questionMatches = (
return (post.title?.length ?? 0) > question.condition.length return (post.title?.length ?? 0) > question.condition.length
case 'title-has-ascii': case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '') return /[A-Za-z0-9]/.test (post.title ?? '')
case 'title-contains':
return (post.title ?? '').includes (question.condition.text)
case 'post-similarity': case 'post-similarity':
return false return false
} }
@@ -311,6 +361,11 @@ export const expectedAnswerForQuestion = (
switch (question.condition.type) switch (question.condition.type)
{ {
case 'post-similarity':
if (question.condition.postId === post.id)
return question.condition.answer
return null
case 'tag': case 'tag':
case 'source': case 'source':
case 'original-year': case 'original-year':
@@ -319,19 +374,26 @@ export const expectedAnswerForQuestion = (
case 'title-length-at-least': case 'title-length-at-least':
case 'title-length-greater-than': case 'title-length-greater-than':
case 'title-has-ascii': case 'title-has-ascii':
case 'title-contains':
return questionMatches (post, question) ? 'yes' : 'no' return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
} }
} }
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
): GekanatorQuestion => { ): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = { const normalizedQuestion = {
...question, ...question,
recordId: question.recordId,
id: normalizedCondition.type === 'title-length-at-least' id: normalizedCondition.type === 'title-length-at-least'
? `title:length-at-least:${ normalizedCondition.length }` ? `title:length-at-least:${ normalizedCondition.length }`
: question.id, : question.id,
@@ -351,6 +413,7 @@ export const storeGekanatorQuestion = (
id: question.condition.type === 'title-length-greater-than' id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }` ? `title:length-at-least:${ question.condition.length + 1 }`
: question.id, : question.id,
recordId: question.recordId,
text: question.text, text: question.text,
kind: question.kind, kind: question.kind,
condition: normalizeTitleLengthCondition (question.condition), condition: normalizeTitleLengthCondition (question.condition),
@@ -382,7 +445,16 @@ export const fetchGekanatorExtraQuestions = async (
} }
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { 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 => const tagCounts = countBy (posts.flatMap (post =>
post.tags post.tags
.filter (tag => .filter (tag =>
@@ -394,27 +466,41 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const originalYears = countBy ( const originalYears = countBy (
posts posts
.map (originalYearOf) .map (originalYearOf)
.filter ((year): year is number => year !== null)) .filter ((year): year is number => year != null))
const originalMonths = countBy ( const originalMonths = countBy (
posts posts
.map (originalMonthOf) .map (originalMonthOf)
.filter ((month): month is number => month !== null)) .filter ((month): month is number => month != null))
const originalMonthDays = countBy ( const originalMonthDays = countBy (
posts posts
.map (originalMonthDayOf) .map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay !== null)) .filter ((monthDay): monthDay is string => monthDay != null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) 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>) => const usefulEntries = <T extends string | number> (
counts: Map<T, number>,
cap: number,
) =>
[...counts.entries ()] [...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length) .filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1]) .sort ((a, b) => Math.abs (posts.length / 2 - a[1])
- Math.abs (posts.length / 2 - b[1])) - Math.abs (posts.length / 2 - b[1]))
.slice (0, 80) .slice (0, cap)
const tagQuestions = usefulEntries (tagCounts) const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 80) .slice (0, tagQuestionCap)
.map (([key]) => { .map (([key]) => {
const { category, name } = tagFromQuestionKey (String (key)) const { category, name } = tagFromQuestionKey (String (key))
const label = category === 'nico' ? nicoTagLabel (name) : name const label = category === 'nico' ? nicoTagLabel (name) : name
@@ -429,7 +515,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
test: (post: Post) => questionableTag (post, String (key)) } test: (post: Post) => questionableTag (post, String (key)) }
}) })
const sourceQuestions = usefulEntries (hosts) const sourceQuestions = usefulEntries (hosts, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([host]) => ({ .map (([host]) => ({
@@ -441,7 +527,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => hostOf (post) === host })) test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears) const originalYearQuestions = usefulEntries (originalYears, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([year]) => ({ .map (([year]) => ({
@@ -453,7 +539,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year })) test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths) const originalMonthQuestions = usefulEntries (originalMonths, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([month]) => ({ .map (([month]) => ({
@@ -465,7 +551,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
priorityWeight: 1, priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month })) test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays) const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20) .slice (0, 20)
.map (([monthDay]) => { .map (([monthDay]) => {
@@ -505,6 +591,23 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const no = posts.length - yes const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7 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 [ return [
...sourceQuestions, ...sourceQuestions,
@@ -512,7 +615,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
...originalMonthQuestions, ...originalMonthQuestions,
...originalMonthDayQuestions, ...originalMonthDayQuestions,
...titleQuestions, ...titleQuestions,
...tagQuestions] ...titleContainsQuestions,
...tagQuestions].slice (0, totalQuestionCap)
} }
@@ -524,7 +628,7 @@ export const saveGekanatorGame = async ({
guessedPostId: number guessedPostId: number
correctPostId: number correctPostId: number
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> => }): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
@@ -532,21 +636,28 @@ export const saveGekanatorGame = async ({
question_id: answer.questionId, question_id: answer.questionId,
question_text: answer.questionText, question_text: answer.questionText,
question_condition: answer.questionCondition ?? null, question_condition: answer.questionCondition ?? null,
question_mode: answer.questionMode,
question_purpose: answer.questionPurpose,
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer, answer: answer.answer,
original_answer: answer.originalAnswer })) }) original_answer: answer.originalAnswer })) })
export const saveGekanatorQuestionSuggestion = async ({ export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId, gekanatorGameId,
existingQuestionId,
questionText, questionText,
answer, answer,
}: { }: {
gekanatorGameId: number gekanatorGameId: number
questionText: string existingQuestionId?: number
questionText?: string
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> => }): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', { await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId, gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText, question_text: questionText,
answer }) answer })
+117 -9
ファイルの表示
@@ -11,6 +11,7 @@ import type {
GekanatorAnswerValue, GekanatorAnswerValue,
GekanatorQuestion, GekanatorQuestion,
} from '@/lib/gekanator' } from '@/lib/gekanator'
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types' import type { Post } from '@/types'
@@ -51,6 +52,21 @@ const postSimilarityQuestion = (
}) })
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 = ( const answer = (
question: GekanatorQuestion, question: GekanatorQuestion,
value: GekanatorAnswerValue, value: GekanatorAnswerValue,
@@ -63,8 +79,17 @@ const answer = (
}) })
const recoveredState = (
answerCountAtRecovery: number,
scoreAtRecovery = 0,
): RecoveredCandidateState => ({
answerCountAtRecovery,
scoreAtRecovery,
})
describe('candidatePostsFor', () => { describe('candidatePostsFor', () => {
it('lets recovered candidates ignore old answers but not later answers', () => { it('does not hard-filter semantic post_similarity answers', () => {
const posts = [post (1), post (2), post (3)] const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', { const oldQuestion = postSimilarityQuestion ('old', {
1: 'no', 1: 'no',
@@ -84,8 +109,31 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1)],
[3, 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]) expect(candidates.map (candidate => candidate.id)).toEqual ([3])
@@ -104,7 +152,7 @@ describe('candidatePostsFor', () => {
answers: [answer (question, 'yes')], answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]), rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, 1]]) }) recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2]) expect(candidates.map (candidate => candidate.id)).toEqual ([2])
}) })
@@ -112,7 +160,7 @@ describe('candidatePostsFor', () => {
describe('hardFilteredPostsForAnswer', () => { describe('hardFilteredPostsForAnswer', () => {
it('returns zero candidates without falling back to the original pool', () => { it('keeps the original pool for semantic post_similarity answers', () => {
const posts = [post (1), post (2)] const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', { const question = postSimilarityQuestion ('question', {
1: 'yes', 1: 'yes',
@@ -123,7 +171,41 @@ describe('hardFilteredPostsForAnswer', () => {
posts, posts,
question, question,
answer: 'no', answer: 'no',
})).toEqual ([]) })).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)
}) })
}) })
@@ -137,7 +219,7 @@ describe('recoverCandidatePosts', () => {
posts, posts,
scores, scores,
rejectedPostIds: new Set ([10]), rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, 1]]), recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
eligiblePostIds: new Set ([9]), eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
recoveryStepCount: 0, recoveryStepCount: 0,
@@ -145,7 +227,33 @@ describe('recoverCandidatePosts', () => {
expect(recovered?.recoveryStepCount).toBe (1) expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4, 3, 2]) .toEqual ([8, 7, 6, 5, 4])
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) 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])
}) })
}) })
+72 -59
ファイルの表示
@@ -1,43 +1,49 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator' import { isLearnedSemanticQuestion,
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types' import type { Post } from '@/types'
export type RecoveredCandidatePost = { export type RecoveredCandidatePost = {
postId: number postId: number
answerCountAtRecovery: number } answerCountAtRecovery: number
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
export const candidatePostsFor = ({ const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
posts, !(isLearnedSemanticQuestion (question)
|| (question.kind === 'tag'
&& question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = (
{ posts,
questions, questions,
answers, answers,
softenedQuestionIds, softenedQuestionIds,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts, recoveredCandidatePosts }: { posts: Post[]
}: {
posts: Post[]
questions: GekanatorQuestion[] questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
}): Post[] => { ): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => { return posts.filter (post => {
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
return false return false
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) const recoveredCandidate = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => { return answers.every ((answer, index) => {
if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
@@ -46,13 +52,18 @@ export const candidatePostsFor = ({
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
if (!(question)) if (!(question))
return true return true
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true
switch (answer.answer) switch (answer.answer)
{ {
case 'yes': case 'yes':
case 'no': { case 'no':
const expected = expectedAnswerForQuestion (question, post) {
return expected === null || expected === 'unknown' || expected === answer.answer const expected = learnedSemanticSideForPost (question, post)
return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
} }
default: default:
return true return true
@@ -62,30 +73,27 @@ export const candidatePostsFor = ({
} }
export const hardFilteredPostsForAnswer = ({ export const hardFilteredPostsForAnswer = (
posts, { posts, question, answer }: { posts: Post[]
question,
answer,
}: {
posts: Post[]
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue answer: GekanatorAnswerValue },
}): Post[] => { ): Post[] => {
if (answer === 'unknown') if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
if (!(answer === 'yes' || answer === 'no'))
return posts return posts
return posts.filter (post => { return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post) const side = learnedSemanticSideForPost (question, post)
return expected === null || expected === 'unknown' || expected === answer return side === 'unknown'
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
}) })
} }
const concreteAnswerOptions: GekanatorAnswerValue[] = [ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no']
'yes',
'no',
'partial',
'probably_no']
export const allConcreteAnswerOptionsExhausted = ( export const allConcreteAnswerOptionsExhausted = (
@@ -100,47 +108,52 @@ export const allConcreteAnswerOptionsExhausted = (
} }
const nextRecoveryBatchSize = (recoveryStepCount: number): number => const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount) 6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = ({ export const recoverCandidatePosts = (
posts, { posts,
scores, scores,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts, recoveredCandidatePosts,
eligiblePostIds, eligiblePostIds,
answerCountAtRecovery, answerCountAtRecovery,
recoveryStepCount, recoveryStepCount }: { posts: Post[]
}: {
posts: Post[]
scores: Map<number, number> scores: Map<number, number>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number recoveryStepCount: number },
}): { ): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveredCandidatePosts: Map<number, number> recoveryStepCount: number } | null => {
recoveryStepCount: number
} | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const candidates = posts const targetSize = nextRecoveryTargetSize (recoveryStepCount)
.filter (post => const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()])
!(rejectedPostIds.has (post.id)) 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)) && !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id))) && !(recovered.has (post.id))))
.sort ((a, b) => .sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
(scores.get (b.id) ?? Number.NEGATIVE_INFINITY) - (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)))
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)) .slice (0, addCount)
.slice (0, nextRecoveryBatchSize (recoveryStepCount))
if (candidates.length === 0) if (candidates.length === 0)
return null return null
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) candidates.forEach (post => recovered.set (post.id, {
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { return { recoveredCandidatePosts: recovered,
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
} }
+8 -1
ファイルの表示
@@ -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),
+15
ファイルの表示
@@ -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'))
+3 -2
ファイルの表示
@@ -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
+86
ファイルの表示
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
@@ -49,6 +52,89 @@ const blocked = (
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) 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', () => { describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
ファイル差分が大きすぎるため省略します 差分を読込み
+4 -1
ファイルの表示
@@ -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: '更新成功!' })
}) })
}) })
+63 -8
ファイルの表示
@@ -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>
+15 -6
ファイルの表示
@@ -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 -1
ファイルの表示
@@ -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}
+21 -1
ファイルの表示
@@ -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"
+23 -4
ファイルの表示
@@ -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
+17 -3
ファイルの表示
@@ -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 (() => {
+35 -2
ファイルの表示
@@ -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="">&nbsp;</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 => (
+54
ファイルの表示
@@ -0,0 +1,54 @@
import { screen } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTagByName: vi.fn (),
}))
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
describe ('WikiDetailPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
const heading = marker.closest ('h1')
const link = screen.getByRole ('link', { name: '旧タグ' })
expect (heading).not.toBeNull ()
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
})
+6 -2
ファイルの表示
@@ -39,6 +39,7 @@ const WikiDetailPage: FC = () => {
queryFn: () => fetchWikiPageByTitle (title, { version }) }) queryFn: () => fetchWikiPageByTitle (title, { version }) })
const effectiveTitle = wikiPage?.title ?? title const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const { data: tag } = useQuery ({ const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
@@ -88,7 +89,7 @@ const WikiDetailPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title> <title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>} {!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
</Helmet> </Helmet>
@@ -110,10 +111,13 @@ const WikiDetailPage: FC = () => {
<article className="prose dark:prose-invert mx-auto p-4"> <article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline"> <h1 className="prose-a:no-underline">
<TagLink tag={tag ?? defaultTag} <TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false} withWiki={false}
withCount={false} withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span></span>}
</h1> </h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
+23
ファイルの表示
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
api.apiGet.mockResolvedValueOnce ({ api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3, wikiPageId: 3,
title: '差分対象', title: '差分対象',
deprecatedAt: null,
olderRevisionId: 1, olderRevisionId: 1,
newerRevisionId: 2, newerRevisionId: 2,
diff: [ diff: [
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
expect (screen.getByText ('added line')).toBeInTheDocument () expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument () expect (screen.getByText ('removed line')).toBeInTheDocument ()
}) })
it ('appends deprecated state to the wiki title', async () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '廃止 Wiki',
deprecatedAt: '2026-06-01T00:00:00.000Z',
olderRevisionId: 1,
newerRevisionId: 2,
diff: [],
})
renderWithProviders (
<Routes>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
</Routes>,
{ route: '/wiki/3/diff?from=1&to=2' },
)
expect (await screen.findByRole ('heading', {
name: '廃止 Wiki(廃止)',
})).toBeInTheDocument ()
})
}) })
+5 -2
ファイルの表示
@@ -23,6 +23,9 @@ const WikiDiffPage: FC = () => {
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const from = query.get ('from') const from = query.get ('from')
const to = query.get ('to') const to = query.get ('to')
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
: ''
useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
@@ -33,9 +36,9 @@ const WikiDiffPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`Wiki 差分: ${ diff?.title } | ${ SITE_TITLE }`}</title> <title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<PageTitle>{diff?.title}</PageTitle> <PageTitle>{displayTitle}</PageTitle>
<div className="prose mx-auto p-4"> <div className="prose mx-auto p-4">
{diff {diff
? ( ? (
+38
ファイルの表示
@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('WikiHistoryPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
api.apiGet.mockResolvedValueOnce ([{
revisionId: 2,
pred: 1,
succ: null,
wikiPage: {
id: 3,
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
},
user: { id: 4, name: 'tester' },
kind: 'content',
message: 'updated',
timestamp: '2026-06-02T00:00:00.000Z',
}])
renderWithProviders (<WikiHistoryPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+1
ファイルの表示
@@ -59,6 +59,7 @@ const WikiHistoryPage: FC = () => {
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
{change.wikiPage.title} {change.wikiPage.title}
</PrefetchLink> </PrefetchLink>
{change.wikiPage.deprecatedAt != null && <span></span>}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.pred == null ? '新規' : '更新'} {change.pred == null ? '新規' : '更新'}
+17
ファイルの表示
@@ -42,4 +42,21 @@ describe ('WikiSearchPage', () => {
}) })
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
}) })
it ('marks deprecated wiki tags in the result title', async () => {
api.apiGet.mockResolvedValueOnce ([
buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}),
])
renderWithProviders (<WikiSearchPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
}) })
+1
ファイルの表示
@@ -86,6 +86,7 @@ const WikiSearchPage: FC = () => {
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title} {page.title}
</PrefetchLink> </PrefetchLink>
{page.deprecatedAt != null && <span></span>}
</td> </td>
<td className="p-2"> <td className="p-2">
{dateString (page.updatedAt)} {dateString (page.updatedAt)}
+7
ファイルの表示
@@ -13,6 +13,7 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 1, id: 1,
name: 'テストタグ', name: 'テストタグ',
category: 'general', category: 'general',
deprecatedAt: null,
aliases: [], aliases: [],
parents: [], parents: [],
postCount: 12, postCount: 12,
@@ -57,6 +58,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
id: 1, id: 1,
title: 'テストWiki', title: 'テストWiki',
deprecatedAt: null,
createdUserId: 1, createdUserId: 1,
updatedUserId: 1, updatedUserId: 1,
createdAt: '2026-01-02T03:04:05.000Z', createdAt: '2026-01-02T03:04:05.000Z',
@@ -70,11 +72,16 @@ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
id: 1, id: 1,
versionNo: 1,
tag: buildTag (), tag: buildTag (),
file: null, file: null,
url: null, url: null,
wikiPageBody: null, wikiPageBody: null,
contentType: null, contentType: null,
fileSuppressedAt: null,
fileSuppressionReason: null,
exportPaths: {},
exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z', createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' }, createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z', updatedAt: '2026-01-03T03:04:05.000Z',
+21 -1
ファイルの表示
@@ -48,6 +48,7 @@ export type FetchTagsParams = {
createdTo: string createdTo: string
updatedFrom: string updatedFrom: string
updatedTo: string updatedTo: string
deprecated: boolean | null
page: number page: number
limit: number limit: number
order: FetchTagsOrder } order: FetchTagsOrder }
@@ -66,16 +67,27 @@ export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type Material = { export type Material = {
id: number id: number
versionNo: number
tag: Tag tag: Tag
file: string | null file: string | null
url: string | null url: string | null
wikiPageBody?: string | null wikiPageBody?: string | null
contentType: string | null contentType: string | null
fileSuppressedAt: string | null
fileSuppressionReason: string | null
exportPaths: Record<string, string>
exportItems: MaterialExportItem[]
createdAt: string createdAt: string
createdByUser: { id: number; name: string } createdByUser: { id: number; name: string }
updatedAt: string updatedAt: string
updatedByUser: { id: number; name: string } } updatedByUser: { id: number; name: string } }
export type MaterialExportItem = {
id: number
profile: string
exportPath: string
enabled: boolean }
export type Menu = MenuItem[] export type Menu = MenuItem[]
export type MenuInvisibleItem = { export type MenuInvisibleItem = {
@@ -139,6 +151,10 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
postSimilarityEdges?: {
targetPostId: number
cos: number
}[]
tags: Tag[] tags: Tag[]
parentPosts?: Post[] parentPosts?: Post[]
childPosts?: Post[] childPosts?: Post[]
@@ -192,6 +208,7 @@ export type Tag = {
id: number id: number
name: string name: string
category: Category category: Category
deprecatedAt: string | null
aliases: string[] aliases: string[]
parents: Tag[] parents: Tag[]
postCount: number postCount: number
@@ -209,6 +226,7 @@ export type TagVersion = {
eventType: 'create' | 'update' | 'discard' | 'restore' eventType: 'create' | 'update' | 'discard' | 'restore'
name: { current: string; prev: string | null } name: { current: string; prev: string | null }
category: { current: Category; prev: Category | null } category: { current: Category; prev: Category | null }
deprecatedAt: { current: string | null; prev: string | null }
aliases: { name: string; type: 'context' | 'added' | 'removed' }[] aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
createdAt: string createdAt: string
@@ -292,6 +310,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
export type WikiPage = { export type WikiPage = {
id: number id: number
title: string title: string
deprecatedAt: string | null
createdUserId: number createdUserId: number
updatedUserId: number updatedUserId: number
createdAt: string createdAt: string
@@ -305,7 +324,7 @@ export type WikiPageChange = {
revisionId: number revisionId: number
pred: number | null pred: number | null
succ: null succ: null
wikiPage: Pick<WikiPage, 'id' | 'title'> wikiPage: Pick<WikiPage, 'id' | 'title' | 'deprecatedAt'>
user: Pick<User, 'id' | 'name'> user: Pick<User, 'id' | 'name'>
kind: 'content' | 'redirect' kind: 'content' | 'redirect'
message: string | null message: string | null
@@ -314,6 +333,7 @@ export type WikiPageChange = {
export type WikiPageDiff = { export type WikiPageDiff = {
wikiPageId: number wikiPageId: number
title: string title: string
deprecatedAt: string | null
olderRevisionId: number | null olderRevisionId: number | null
newerRevisionId: number newerRevisionId: number
diff: WikiPageDiffDiff[] } diff: WikiPageDiffDiff[] }
+2 -1
ファイルの表示
@@ -27,5 +27,6 @@
"@/*": ["*"] "@/*": ["*"]
} }
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
} }