コミットを比較
13 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 546a212e74 | |||
| 201fe72e5a | |||
| 6e338c8616 | |||
| be2df723fe | |||
| 39d86f4778 | |||
| 69820265fd | |||
| 4b26f017b4 | |||
| a50c29cc35 | |||
| 364d154b6a | |||
| b1362d327c | |||
| 81e620c33a | |||
| 62857adb87 | |||
| 09763982b5 |
@@ -107,16 +107,11 @@ npm run preview
|
||||
- Prefer single quotes for strings unless interpolation or escaping makes
|
||||
double quotes better.
|
||||
- Ruby: never put a space before method-call parentheses.
|
||||
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
||||
- Ruby: never put a line break immediately before `)`.
|
||||
- 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 keep the first pair on the same line as `{` unless line length
|
||||
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
|
||||
indentation.
|
||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||
@@ -130,89 +125,13 @@ npm run preview
|
||||
- TypeScript and TSX use 4-space logical indentation.
|
||||
- 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.
|
||||
- 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 breaking a line at an operator, break before the
|
||||
operator and put the operator at the beginning of the next line. A trailing
|
||||
operator at end of line is unacceptable. This rule does not apply to Ruby,
|
||||
where it can change the syntactic structure.
|
||||
- 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 create, modify, or run tests unless the user explicitly asks for
|
||||
test work. When the user asks for tests, keep working and rerun them until
|
||||
they pass or the remaining failure is clearly blocked.
|
||||
|
||||
## Backend rules
|
||||
|
||||
- Inspect existing routes, controllers, models, services, and specs before
|
||||
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
|
||||
`backend/spec/requests` only when the user explicitly asks for tests.
|
||||
- For API behavior changes, add or update request specs under `backend/spec/requests`.
|
||||
- Prefer RSpec for new backend tests; existing minitest files under
|
||||
`backend/test` do not make minitest the default for new coverage.
|
||||
- Do not weaken authentication, BAN user checks, or IP BAN checks.
|
||||
@@ -235,29 +154,6 @@ const value =
|
||||
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
|
||||
under `frontend/src/components` unless existing patterns point elsewhere.
|
||||
- Match existing Tailwind, component, and import alias conventions.
|
||||
- `<a href="#">` is acceptable for event-only controls when it fits the local
|
||||
UI pattern. Do not use `<a>` for internal navigation or other non-external
|
||||
links.
|
||||
- Internal links must use `PrefetchLink`.
|
||||
- External links must use `<a>` with `target="_blank"`.
|
||||
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
|
||||
explicit readable `text-*` color and dark-mode counterparts such as
|
||||
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
|
||||
- Do not rely on inherited text color for light backgrounds. This is especially
|
||||
important for chips, cards, buttons, and panels that may inherit white text in
|
||||
dark mode.
|
||||
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
|
||||
make dense controls wrap or scroll intentionally, and keep tag/filter
|
||||
controls usable without horizontal page overflow.
|
||||
- For mobile horizontal scrollers, make the scroll direction and item sizing
|
||||
explicit, and ensure chip text remains readable in both light and dark modes.
|
||||
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
|
||||
`!==` 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
|
||||
|
||||
@@ -268,20 +164,7 @@ const value =
|
||||
- Put two blank lines before and after top-level `const` function
|
||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||
- In TSX, indent with 4-space logical indentation.
|
||||
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab
|
||||
character.
|
||||
- A leading tab is exactly equivalent to 8 leading spaces.
|
||||
- Never place a closing parenthesis at the beginning of a line.
|
||||
- Never place a closing square bracket at the beginning of a line.
|
||||
- For object literals and other associative-array-style braces, do not place
|
||||
the closing brace at the beginning of a line. Function, lambda, callback, and
|
||||
block closing braces are exempt and should stay on their own line when that
|
||||
fits the local style.
|
||||
- When writing braces on a single line in TypeScript or TSX JavaScript
|
||||
context, put exactly one space inside the braces, as in `{ value }` or
|
||||
`{ key: value }`.
|
||||
- Do not add inner spaces to React/JSX expression braces, as in
|
||||
`prop={value}`, `{children}`, or `<Component>{{...props}}</Component>`.
|
||||
- Keep a tag's closing marker on the same line as the final prop when the tag
|
||||
spans multiple lines.
|
||||
- Do not put `/>` or `>` on its own line unless the existing surrounding code
|
||||
@@ -292,9 +175,6 @@ const value =
|
||||
single physical line.
|
||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||
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:
|
||||
|
||||
@@ -331,11 +211,10 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
||||
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
||||
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
||||
behavior, inspect the related request specs and service objects.
|
||||
- If frontend code changes, run only non-test verification commands that
|
||||
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
|
||||
only when the user explicitly asks for tests.
|
||||
- If backend code changes, do not run RSpec unless the user explicitly asks
|
||||
for tests.
|
||||
- If frontend code changes, run the existing frontend verification commands
|
||||
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
|
||||
- If backend code changes, run the relevant RSpec command; for broad backend
|
||||
changes, run `bundle exec rspec`.
|
||||
- If a verification command cannot be run or fails, report the exact command and failure.
|
||||
|
||||
## Completion criteria
|
||||
@@ -343,8 +222,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
||||
A task is complete only when:
|
||||
|
||||
- implementation is complete,
|
||||
- relevant non-test verification commands pass, or failures are clearly
|
||||
explained,
|
||||
- relevant verification commands pass, or failures are clearly explained,
|
||||
- unrelated files are not changed,
|
||||
- migrations and schema are consistent when schema changes are made,
|
||||
- user-facing behavior is documented when needed.
|
||||
|
||||
+4
-17
@@ -47,10 +47,6 @@ bundle exec rspec
|
||||
|
||||
If a command cannot be run or fails, report the exact command and failure.
|
||||
|
||||
Do not create, modify, or run tests unless the user explicitly asks for test
|
||||
work. When the user asks for tests, keep working and rerun them until they
|
||||
pass or the remaining failure is clearly blocked.
|
||||
|
||||
## Rails structure
|
||||
|
||||
- `app/controllers`: API controllers.
|
||||
@@ -72,23 +68,17 @@ service, representation, and spec.
|
||||
- Prefer precise, minimal changes.
|
||||
- Use single quotes unless interpolation or escaping makes double quotes better.
|
||||
- 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.
|
||||
- Do not use `%w` or `%i` in new Ruby code.
|
||||
- Never write a Ruby line longer than 99 characters.
|
||||
- Aim to keep Ruby lines within 79 characters where practical.
|
||||
- For small Ruby method definitions that take keyword arguments, match the
|
||||
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
|
||||
rules.
|
||||
- Do not format Ruby hashes like Ruby blocks.
|
||||
- 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.
|
||||
- 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
|
||||
by 4 spaces.
|
||||
- Put one logical pair per line when the expression would otherwise become
|
||||
@@ -126,8 +116,7 @@ service, representation, and spec.
|
||||
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
||||
- Do not weaken BAN or IP BAN behavior.
|
||||
- If changing request authentication or controller before actions, add or
|
||||
update request specs covering banned users and banned IP addresses only when
|
||||
the user explicitly asks for tests.
|
||||
update request specs covering banned users and banned IP addresses.
|
||||
|
||||
## RSpec
|
||||
|
||||
@@ -141,9 +130,8 @@ service, representation, and spec.
|
||||
- `AuthHelper#sign_in_as(user)` stubs
|
||||
`ApplicationController#current_user`; use it when matching existing
|
||||
request spec style.
|
||||
- Add or update request specs for API behavior changes only when the user
|
||||
explicitly asks for tests, especially status codes, permissions, response
|
||||
shape, and version conflict behavior.
|
||||
- Add or update request specs for API behavior changes, especially status
|
||||
codes, permissions, response shape, and version conflict behavior.
|
||||
|
||||
## Migrations
|
||||
|
||||
@@ -176,8 +164,7 @@ service, representation, and spec.
|
||||
the record `version_no`.
|
||||
- Do not update versioned records without considering whether a version snapshot must be created.
|
||||
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
|
||||
`merge` semantics. Cover conflicts in request specs only when the user
|
||||
explicitly asks for tests.
|
||||
`merge` semantics and cover conflicts in request specs.
|
||||
|
||||
## Domain cautions
|
||||
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
class GekanatorGamesController < ApplicationController
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
guessed_post_id = params.require(:guessed_post_id)
|
||||
correct_post_id = params[:correct_post_id].presence
|
||||
answers = params.require(:answers).as_json
|
||||
|
||||
game = GekanatorGame.new(
|
||||
user: current_user,
|
||||
guessed_post_id:,
|
||||
correct_post_id:,
|
||||
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
|
||||
question_count: answers.length,
|
||||
answers:)
|
||||
|
||||
if game.invalid?
|
||||
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
learned_example_count = 0
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
game.save!
|
||||
learned_example_count = learn_answers_from_game!(game)
|
||||
end
|
||||
|
||||
render json: {
|
||||
id: game.id,
|
||||
learned_example_count:
|
||||
}, status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def extra_questions
|
||||
game = find_owned_game
|
||||
return if performed?
|
||||
|
||||
questions =
|
||||
GekanatorQuestion
|
||||
.accepted
|
||||
.includes(:gekanator_question_examples)
|
||||
.where(kind: 'post_similarity', source: 'user_suggested')
|
||||
.to_a
|
||||
|
||||
selected =
|
||||
prioritized_extra_questions(
|
||||
questions,
|
||||
post_id: game.correct_post_id,
|
||||
user: current_user,
|
||||
limit: 6)
|
||||
|
||||
render json: {
|
||||
questions: selected.map { |question| extra_question_json(question) }
|
||||
}
|
||||
end
|
||||
|
||||
def extra_question_answers
|
||||
game = find_owned_game
|
||||
return if performed?
|
||||
|
||||
answer_params = params.require(:answers)
|
||||
if !answer_params.is_a?(Array)
|
||||
return render_validation_error fields: { answers: ['配列で指定してください.'] }
|
||||
end
|
||||
|
||||
answers = answer_params.map { |answer|
|
||||
{
|
||||
question_id: answer.require(:question_id).to_i,
|
||||
answer: answer.require(:answer)
|
||||
}
|
||||
}
|
||||
questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] })
|
||||
question_by_id = questions.index_by(&:id)
|
||||
if questions.length != answers.length
|
||||
return render_validation_error fields: { answers: ['質問が見つかりません.'] }
|
||||
end
|
||||
if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' }
|
||||
return render_validation_error fields: { answers: ['質問が不正です.'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
answers.each do |item|
|
||||
question = question_by_id[item[:question_id]]
|
||||
example =
|
||||
GekanatorQuestionExample.find_or_initialize_by(
|
||||
gekanator_question: question,
|
||||
post: game.correct_post,
|
||||
user: current_user)
|
||||
example.record_answer!(
|
||||
answer: item[:answer],
|
||||
source: 'post_game_extra',
|
||||
gekanator_game: game)
|
||||
example.save!
|
||||
end
|
||||
end
|
||||
|
||||
render json: { count: answers.length }, status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extra_question_json question
|
||||
{
|
||||
id: question.id,
|
||||
text: question.text,
|
||||
source: question.source,
|
||||
priority_weight: question.priority_weight
|
||||
}
|
||||
end
|
||||
|
||||
def prioritized_extra_questions questions, post_id:, user:, limit:
|
||||
answered_question_ids =
|
||||
GekanatorQuestionExample
|
||||
.where(user:, gekanator_question_id: questions.map(&:id))
|
||||
.distinct
|
||||
.pluck(:gekanator_question_id)
|
||||
unanswered, answered =
|
||||
questions.partition { |question| !answered_question_ids.include?(question.id) }
|
||||
selected = weighted_sample_questions(unanswered, post_id:, limit:)
|
||||
return selected if selected.length >= limit
|
||||
|
||||
selected + weighted_sample_questions(
|
||||
answered.reject { |question| selected.any? { _1.id == question.id } },
|
||||
post_id:,
|
||||
limit: limit - selected.length)
|
||||
end
|
||||
|
||||
def weighted_sample_questions questions, post_id:, limit:
|
||||
remaining = questions.uniq(&:id)
|
||||
selected = []
|
||||
|
||||
while selected.length < limit && remaining.any?
|
||||
weighted =
|
||||
remaining.map { |question|
|
||||
[question, selection_weight_for(question, post_id: post_id)]
|
||||
}
|
||||
total_weight = weighted.sum { |_question, weight| weight }
|
||||
break if total_weight <= 0
|
||||
|
||||
target = rand * total_weight
|
||||
cumulative = 0.0
|
||||
chosen =
|
||||
weighted.find do |_question, weight|
|
||||
cumulative += weight
|
||||
cumulative >= target
|
||||
end&.first || weighted.first.first
|
||||
|
||||
selected << chosen
|
||||
remaining.reject! { |question| question.id == chosen.id }
|
||||
end
|
||||
|
||||
selected
|
||||
end
|
||||
|
||||
def selection_weight_for question, post_id:
|
||||
sample_count =
|
||||
question.gekanator_question_examples.sum { |example|
|
||||
next 0 unless example.post_id == post_id
|
||||
|
||||
example.sample_count.presence || 1
|
||||
}
|
||||
|
||||
question.priority_weight.to_f / (1.0 + sample_count * 0.15)
|
||||
end
|
||||
|
||||
def find_owned_game
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
game = GekanatorGame.find_by(id: params[:id])
|
||||
return head :not_found unless game
|
||||
if !current_user.admin? && game.user_id != current_user.id
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
game
|
||||
end
|
||||
|
||||
def learn_answers_from_game! game
|
||||
correct_post = game.correct_post
|
||||
return 0 if correct_post.blank?
|
||||
|
||||
accepted_questions =
|
||||
GekanatorQuestion
|
||||
.accepted
|
||||
.index_by { |question| public_question_id_for(question) }
|
||||
learned_count = 0
|
||||
|
||||
Array(game.answers).each do |answer|
|
||||
answer_value = answer['answer'].to_s
|
||||
next if answer_value.blank? || answer_value == 'unknown'
|
||||
|
||||
question_id = game_answer_question_id(answer)
|
||||
next if question_id.blank?
|
||||
|
||||
question = accepted_questions[question_id.to_s]
|
||||
next unless learnable_game_answer_question?(question)
|
||||
|
||||
example =
|
||||
GekanatorQuestionExample.find_or_initialize_by(
|
||||
gekanator_question: question,
|
||||
post: correct_post,
|
||||
user: current_user)
|
||||
example.record_answer!(
|
||||
answer: answer_value,
|
||||
source: 'post_game_answer',
|
||||
gekanator_game: game)
|
||||
example.save!
|
||||
learned_count += 1
|
||||
end
|
||||
|
||||
learned_count
|
||||
end
|
||||
|
||||
def public_question_id_for question
|
||||
condition = normalize_condition(question.condition)
|
||||
|
||||
case condition[:type]
|
||||
when 'tag'
|
||||
"tag:#{condition[:key]}"
|
||||
when 'source'
|
||||
"source:#{condition[:host]}"
|
||||
when 'original-year'
|
||||
"original-year:#{condition[:year]}"
|
||||
when 'original-month'
|
||||
"original-month:#{condition[:month]}"
|
||||
when 'original-month-day'
|
||||
"original-month-day:#{condition[:monthDay] || condition[:month_day]}"
|
||||
when 'title-length-at-least'
|
||||
"title:length-at-least:#{condition[:length]}"
|
||||
when 'title-length-greater-than'
|
||||
"title:length-at-least:#{condition[:length].to_i + 1}"
|
||||
when 'title-has-ascii'
|
||||
'title:ascii'
|
||||
when 'title-contains'
|
||||
"title:contains:#{condition[:text]}"
|
||||
when 'post-similarity'
|
||||
"post-similarity:#{question.id}"
|
||||
else
|
||||
"catalog:#{question.id}"
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_condition condition
|
||||
json = condition.deep_dup.as_json
|
||||
|
||||
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||
json['monthDay'] = json.delete('month_day')
|
||||
end
|
||||
|
||||
json.deep_symbolize_keys
|
||||
end
|
||||
|
||||
def learnable_game_answer_question? question
|
||||
return false if question.nil?
|
||||
return true if question.kind == 'post_similarity'
|
||||
return false unless question.kind == 'tag'
|
||||
|
||||
condition = normalize_condition(question.condition)
|
||||
key = condition[:key].to_s
|
||||
!key.start_with?('nico:')
|
||||
end
|
||||
|
||||
def game_answer_question_id answer
|
||||
answer['question_id'] ||
|
||||
answer[:question_id] ||
|
||||
answer['questionId'] ||
|
||||
answer[:questionId]
|
||||
end
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
class GekanatorPostsController < ApplicationController
|
||||
def index
|
||||
posts =
|
||||
Post
|
||||
.preload(:post_similarities, tags: :tag_name)
|
||||
.with_attached_thumbnail
|
||||
.order(Arel.sql(
|
||||
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
||||
.to_a
|
||||
|
||||
active_tags_by_post_id =
|
||||
posts.each_with_object({ }) do |post, h|
|
||||
h[post.id] = post.tags.reject(&:deprecated?)
|
||||
end
|
||||
|
||||
render json: {
|
||||
posts: posts.map { |post|
|
||||
post_json(post,
|
||||
active_tags_by_post_id:)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post_json post, active_tags_by_post_id:
|
||||
{
|
||||
id: post.id,
|
||||
url: post.url,
|
||||
title: post.title,
|
||||
thumbnail: thumbnail_url(post),
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
post_similarity_edges: post_similarity_edges_json(
|
||||
post,
|
||||
active_tags_by_post_id:),
|
||||
tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
|
||||
}
|
||||
end
|
||||
|
||||
def post_similarity_edges_json post, active_tags_by_post_id:
|
||||
post
|
||||
.post_similarities
|
||||
.filter_map do |similarity|
|
||||
next unless active_tags_by_post_id.key?(similarity.target_post_id)
|
||||
|
||||
{
|
||||
target_post_id: similarity.target_post_id,
|
||||
cos: similarity.cos.to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def tag_json tag
|
||||
{
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
category: tag.category
|
||||
}
|
||||
end
|
||||
|
||||
def thumbnail_url post
|
||||
return nil unless post.thumbnail.attached?
|
||||
|
||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -1,95 +0,0 @@
|
||||
class GekanatorQuestionSuggestionsController < ApplicationController
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
|
||||
return head :not_found unless game
|
||||
if !current_user.admin? && game.user_id != current_user.id
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
existing_question_id = params[:existing_question_id].presence
|
||||
if existing_question_id
|
||||
question = GekanatorQuestion.accepted.find_by(id: existing_question_id)
|
||||
return head :not_found unless question
|
||||
unless learnable_existing_question?(question)
|
||||
return render_validation_error fields: { existing_question_id: ['質問が不正です.'] }
|
||||
end
|
||||
|
||||
example =
|
||||
GekanatorQuestionExample.find_or_initialize_by(
|
||||
gekanator_question: question,
|
||||
post: game.correct_post,
|
||||
user: current_user)
|
||||
example.record_answer!(
|
||||
answer: params.require(:answer),
|
||||
source: 'post_game_extra',
|
||||
gekanator_game: game)
|
||||
|
||||
if example.save
|
||||
render json: {
|
||||
id: question.id,
|
||||
count: game.question_suggestions.count
|
||||
}, status: :created
|
||||
else
|
||||
render_validation_error example
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
suggestion = GekanatorQuestionSuggestion.new(
|
||||
gekanator_game: game,
|
||||
user: current_user,
|
||||
question_text: params.require(:question_text),
|
||||
answer: params.require(:answer))
|
||||
|
||||
if suggestion.valid?
|
||||
ActiveRecord::Base.transaction do
|
||||
suggestion.save!
|
||||
Gekanator::QuestionSuggestionPromoter.call(
|
||||
suggestion: suggestion,
|
||||
user: current_user)
|
||||
end
|
||||
|
||||
render json: {
|
||||
id: suggestion.id,
|
||||
count: game.question_suggestions.count
|
||||
}, status: :created
|
||||
else
|
||||
render_validation_error suggestion
|
||||
end
|
||||
end
|
||||
|
||||
def ai_convert
|
||||
return head :not_found unless current_user&.admin?
|
||||
|
||||
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
|
||||
return head :not_found unless suggestion
|
||||
if Gekanator::AiRunBudget.exceeded_after_next_run?
|
||||
suggestion.gekanator_ai_runs.create!(
|
||||
model: 'budget_guard',
|
||||
status: 'blocked_budget',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
estimated_cost_jpy: 0)
|
||||
return head :payment_required
|
||||
end
|
||||
|
||||
Gekanator::QuestionSuggestionAiConverter.call(
|
||||
suggestion: suggestion,
|
||||
user: current_user)
|
||||
head :no_content
|
||||
rescue NotImplementedError
|
||||
head :not_implemented
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def learnable_existing_question? question
|
||||
return true if question.kind == 'post_similarity'
|
||||
return false unless question.kind == 'tag'
|
||||
|
||||
key = question.condition.as_json['key'].to_s
|
||||
!key.start_with?('nico:')
|
||||
end
|
||||
end
|
||||
@@ -1,147 +0,0 @@
|
||||
class GekanatorQuestionsController < ApplicationController
|
||||
def index
|
||||
questions =
|
||||
GekanatorQuestion
|
||||
.accepted
|
||||
.includes(:gekanator_question_examples)
|
||||
.order(priority_weight: :desc, id: :asc)
|
||||
.to_a
|
||||
deprecated_tag_keys = deprecated_tag_keys_for(questions)
|
||||
|
||||
render json: {
|
||||
questions: questions.filter_map { |question|
|
||||
json = question_json(question)
|
||||
next if hidden_question?(json[:condition], deprecated_tag_keys)
|
||||
|
||||
json
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def question_json question
|
||||
condition = condition_json(question.condition).deep_symbolize_keys
|
||||
json = {
|
||||
record_id: question.id,
|
||||
id: question_id_for(question, condition),
|
||||
text: question_text_for(question, condition),
|
||||
kind: question.kind,
|
||||
condition: condition,
|
||||
source: question.source,
|
||||
priority_weight: question.priority_weight
|
||||
}
|
||||
if question.kind == 'post_similarity' || question.kind == 'tag'
|
||||
json[:example_answers] = example_answers_json(question)
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def question_id_for question, condition
|
||||
case condition[:type]
|
||||
when 'tag'
|
||||
"tag:#{ condition[:key] }"
|
||||
when 'source'
|
||||
"source:#{ condition[:host] }"
|
||||
when 'original-year'
|
||||
"original-year:#{ condition[:year] }"
|
||||
when 'original-month'
|
||||
"original-month:#{ condition[:month] }"
|
||||
when 'original-month-day'
|
||||
"original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
|
||||
when 'title-length-at-least'
|
||||
"title:length-at-least:#{ condition[:length] }"
|
||||
when 'title-length-greater-than'
|
||||
"title:length-at-least:#{ condition[:length].to_i + 1 }"
|
||||
when 'title-has-ascii'
|
||||
'title:ascii'
|
||||
when 'title-contains'
|
||||
"title:contains:#{ condition[:text] }"
|
||||
when 'post-similarity'
|
||||
"post-similarity:#{ question.id }"
|
||||
else
|
||||
"catalog:#{ question.id }"
|
||||
end
|
||||
end
|
||||
|
||||
def condition_json condition
|
||||
json = condition.deep_dup.as_json
|
||||
|
||||
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||
json['monthDay'] = json.delete('month_day')
|
||||
end
|
||||
|
||||
if json['type'] == 'title-length-greater-than'
|
||||
json['type'] = 'title-length-at-least'
|
||||
json['length'] = json['length'].to_i + 1
|
||||
end
|
||||
|
||||
json
|
||||
end
|
||||
|
||||
def question_text_for question, condition
|
||||
return question.text unless question.kind == 'title'
|
||||
|
||||
case condition[:type]
|
||||
when 'title-length-at-least'
|
||||
"タイトルは #{ condition[:length] } 文字以上?"
|
||||
when 'title-contains'
|
||||
"題名に「#{ condition[:text] }」が含まれる?"
|
||||
else
|
||||
question.text
|
||||
end
|
||||
end
|
||||
|
||||
def example_answers_json question
|
||||
question
|
||||
.gekanator_question_examples
|
||||
.group_by(&:post_id)
|
||||
.transform_values { |examples| aggregate_answer(examples) }
|
||||
end
|
||||
|
||||
def aggregate_answer examples
|
||||
examples
|
||||
.group_by(&:answer)
|
||||
.map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
|
||||
.sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
|
||||
.first
|
||||
&.first
|
||||
end
|
||||
|
||||
def deprecated_tag_keys_for questions
|
||||
tag_keys = questions.filter_map { |question|
|
||||
condition = condition_json(question.condition)
|
||||
next unless condition['type'] == 'tag'
|
||||
|
||||
condition['key'].to_s.presence
|
||||
}.uniq
|
||||
return {} if tag_keys.empty?
|
||||
|
||||
categories = []
|
||||
names = []
|
||||
tag_keys.each do |key|
|
||||
category, name = parse_tag_key(key)
|
||||
categories << category
|
||||
names << name
|
||||
end
|
||||
|
||||
Tag
|
||||
.joins(:tag_name)
|
||||
.where(category: categories.uniq)
|
||||
.where(tag_names: { name: names.uniq })
|
||||
.where.not(deprecated_at: nil)
|
||||
.pluck('tags.category', 'tag_names.name')
|
||||
.each_with_object({ }) do |(category, name), h|
|
||||
h["#{ category }:#{ name }"] = true
|
||||
end
|
||||
end
|
||||
|
||||
def hidden_question? condition, deprecated_tag_keys
|
||||
condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s]
|
||||
end
|
||||
|
||||
def parse_tag_key key
|
||||
parts = key.to_s.split(':')
|
||||
[parts.first.to_s, parts.drop(1).join(':')]
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,4 @@
|
||||
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
|
||||
page = (params[:page].presence || 1).to_i
|
||||
limit = (params[:limit].presence || 20).to_i
|
||||
@@ -12,93 +8,54 @@ class MaterialsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
filters = material_index_filters
|
||||
q = Material.includes(:material_export_items,
|
||||
thumbnail_attachment: :blob,
|
||||
file_attachment: :blob,
|
||||
tag: :tag_name)
|
||||
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
|
||||
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
|
||||
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
|
||||
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
|
||||
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
|
||||
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to]
|
||||
q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from]
|
||||
q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to]
|
||||
tag_id = params[:tag_id].presence
|
||||
parent_id = params[:parent_id].presence
|
||||
|
||||
q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
|
||||
q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
|
||||
q = apply_material_query(q, filters[:q]) if filters[:q].present?
|
||||
q = apply_material_media_kind(q, filters[:media_kind])
|
||||
q = Material.includes(:tag, :created_by_user).with_attached_file
|
||||
q = q.where(tag_id:) if tag_id
|
||||
q = q.where(parent_id:) if parent_id
|
||||
|
||||
count = q.distinct.count(:id)
|
||||
materials =
|
||||
q
|
||||
.order(Arel.sql(material_index_order_sql(filters)))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.to_a
|
||||
count = q.count
|
||||
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
||||
|
||||
render json: { materials: MaterialRepr.list_many(materials, host: request.base_url),
|
||||
count: }
|
||||
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
|
||||
end
|
||||
|
||||
def show
|
||||
material =
|
||||
Material
|
||||
.includes(:tag, :material_export_items)
|
||||
.with_attached_thumbnail
|
||||
.includes(:tag)
|
||||
.with_attached_file
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
|
||||
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
tag_name_raw = params[:tag].to_s.strip
|
||||
file = params[:file]
|
||||
file_sha256 = MaterialFileSha256.from_upload(file)
|
||||
url = params[:url].to_s.strip.presence
|
||||
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
||||
if file.blank? && url.blank?
|
||||
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
||||
url: ['ファイルまたは URL は必須です.'] }
|
||||
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
|
||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||
tag = tag_name.tag
|
||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||
|
||||
begin
|
||||
Material.transaction do
|
||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||
tag = tag_name.tag
|
||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||
material = Material.new(tag:, url:,
|
||||
created_by_user: current_user,
|
||||
updated_by_user: current_user)
|
||||
material.file.attach(file)
|
||||
|
||||
material = Material.new(tag:, url:,
|
||||
created_by_user: current_user,
|
||||
updated_by_user: current_user)
|
||||
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
|
||||
MaterialThumbnailGenerator.generate!(material)
|
||||
material.reload
|
||||
if material.save
|
||||
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
||||
else
|
||||
render_validation_error material
|
||||
@@ -114,46 +71,29 @@ class MaterialsController < ApplicationController
|
||||
|
||||
tag_name_raw = params[:tag].to_s.strip
|
||||
file = params[:file]
|
||||
file_sha256 = MaterialFileSha256.from_upload(file)
|
||||
url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
|
||||
url = params[:url].to_s.strip.presence
|
||||
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
|
||||
if file.blank? && url.blank? && !material.file.attached?
|
||||
if file.blank? && url.blank?
|
||||
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
|
||||
url: ['ファイルまたは URL は必須です.'] }
|
||||
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)
|
||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||
tag = tag_name.tag
|
||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||
|
||||
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 = tag_name.tag
|
||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||
|
||||
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||
if uploaded_blob
|
||||
material.file.attach(uploaded_blob)
|
||||
clear_file_suppression!(material)
|
||||
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
|
||||
material.update!(tag:, url:, updated_by_user: current_user)
|
||||
if file
|
||||
material.file.attach(file)
|
||||
else
|
||||
material.file.purge
|
||||
end
|
||||
|
||||
MaterialThumbnailGenerator.generate!(material)
|
||||
material.reload
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url)
|
||||
if material.save
|
||||
render json: MaterialRepr.base(material, host: request.base_url)
|
||||
else
|
||||
render_validation_error material
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -163,274 +103,7 @@ class MaterialsController < ApplicationController
|
||||
material = Material.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
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
|
||||
material.discard
|
||||
head :no_content
|
||||
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 material_index_filters
|
||||
tag_state = params[:tag_state].to_s.presence
|
||||
tag_state = 'untagged' if bool?(:unclassified)
|
||||
tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state)
|
||||
|
||||
media_kind = params[:media_kind].to_s.presence
|
||||
unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind)
|
||||
media_kind = 'all'
|
||||
end
|
||||
|
||||
suppression = params[:suppression].to_s.presence
|
||||
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
|
||||
|
||||
sort = params[:sort].to_s.presence
|
||||
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
|
||||
'version_no', 'id'].include?(sort)
|
||||
sort = 'created_at'
|
||||
end
|
||||
|
||||
direction = params[:direction].to_s.downcase
|
||||
direction = 'desc' unless ['asc', 'desc'].include?(direction)
|
||||
|
||||
{ q: params[:q].to_s.strip.presence,
|
||||
tag_state:,
|
||||
media_kind:,
|
||||
suppression:,
|
||||
created_from: parse_time_param(:created_from),
|
||||
created_to: parse_time_param(:created_to),
|
||||
updated_from: parse_time_param(:updated_from),
|
||||
updated_to: parse_time_param(:updated_to),
|
||||
sort:,
|
||||
direction: }
|
||||
end
|
||||
|
||||
def parse_time_param name
|
||||
value = params[name].to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
Time.zone.parse(value)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def material_index_needs_tag_name? filters
|
||||
filters[:q].present? || filters[:sort] == 'tag_name'
|
||||
end
|
||||
|
||||
def material_index_needs_file_blob? filters
|
||||
filters[:q].present? ||
|
||||
filters[:media_kind] != 'all' ||
|
||||
['media_kind', 'file_byte_size'].include?(filters[:sort])
|
||||
end
|
||||
|
||||
def material_index_join_tag_name q
|
||||
q.left_joins(tag: :tag_name)
|
||||
end
|
||||
|
||||
def material_index_join_file_blob q
|
||||
q.joins(<<~SQL.squish)
|
||||
LEFT JOIN active_storage_attachments material_file_attachments
|
||||
ON material_file_attachments.record_type = 'Material'
|
||||
AND material_file_attachments.record_id = materials.id
|
||||
AND material_file_attachments.name = 'file'
|
||||
LEFT JOIN active_storage_blobs material_file_blobs
|
||||
ON material_file_blobs.id = material_file_attachments.blob_id
|
||||
SQL
|
||||
end
|
||||
|
||||
def apply_material_query q, term
|
||||
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
|
||||
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
|
||||
'material_file_blobs.filename LIKE :q',
|
||||
q: like)
|
||||
end
|
||||
|
||||
def apply_material_media_kind q, media_kind
|
||||
case media_kind
|
||||
when 'image'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'image/%')
|
||||
when 'video'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'video/%')
|
||||
when 'audio'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'audio/%')
|
||||
when 'file_other'
|
||||
q.where('material_file_attachments.id IS NOT NULL')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'image/%')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'video/%')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'audio/%')
|
||||
when 'url_only'
|
||||
q.where('material_file_attachments.id IS NULL').where.not(url: [nil, ''])
|
||||
else
|
||||
q
|
||||
end
|
||||
end
|
||||
|
||||
def material_index_order_sql filters
|
||||
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
|
||||
sort_sql =
|
||||
case filters[:sort]
|
||||
when 'tag_name'
|
||||
'tag_names.name'
|
||||
when 'media_kind'
|
||||
material_media_kind_sql
|
||||
when 'file_byte_size'
|
||||
'material_file_blobs.byte_size'
|
||||
when 'updated_at'
|
||||
'materials.updated_at'
|
||||
when 'version_no'
|
||||
'materials.version_no'
|
||||
when 'id'
|
||||
'materials.id'
|
||||
else
|
||||
'materials.created_at'
|
||||
end
|
||||
|
||||
"#{ sort_sql } #{ direction }, materials.id #{ direction }"
|
||||
end
|
||||
|
||||
def material_media_kind_sql
|
||||
"CASE " \
|
||||
"WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \
|
||||
"WHEN material_file_attachments.id IS NOT NULL THEN 4 " \
|
||||
"ELSE 6 END"
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -148,10 +148,10 @@ class PostsController < ApplicationController
|
||||
ApplicationRecord.transaction do
|
||||
post.save!
|
||||
|
||||
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
|
||||
tags = Tag.normalise_tags!(tag_names)
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
||||
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
sync_post_tags!(post, tags)
|
||||
|
||||
sync_parent_posts!(post, parent_post_ids)
|
||||
@@ -165,8 +165,6 @@ class PostsController < ApplicationController
|
||||
render json: PostRepr.base(post), status: :created
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||
rescue ArgumentError => e
|
||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -257,8 +255,6 @@ class PostsController < ApplicationController
|
||||
render json:, status: :ok
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||
rescue ArgumentError => e
|
||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -382,7 +378,7 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def build_tag_tree_for tags
|
||||
tags = tags.reject(&:deprecated?).to_a
|
||||
tags = tags.to_a
|
||||
tag_ids = tags.map(&:id)
|
||||
|
||||
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
||||
@@ -505,8 +501,7 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def editable_tag_names_from_post post
|
||||
post.tags.not_nico.where(deprecated_at: nil)
|
||||
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
end
|
||||
|
||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
||||
@@ -538,10 +533,9 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def incoming_tag_names_for_snapshot raw_tag_names
|
||||
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
|
||||
deny_deprecated: true)
|
||||
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
|
||||
|
||||
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
|
||||
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
|
||||
end
|
||||
|
||||
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
||||
@@ -628,14 +622,13 @@ class PostsController < ApplicationController
|
||||
original_created_from: snapshot[:original_created_from],
|
||||
original_created_before: snapshot[:original_created_before])
|
||||
|
||||
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
|
||||
deny_deprecated: true)
|
||||
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
|
||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
||||
|
||||
readonly_tags = post.tags.nico.to_a
|
||||
|
||||
tags = readonly_tags + editable_tags
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
|
||||
sync_post_tags!(post, tags)
|
||||
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
||||
|
||||
@@ -17,7 +17,6 @@ class TagVersionsController < ApplicationController
|
||||
AND prev.version_no = tag_versions.version_no - 1
|
||||
SQL
|
||||
.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')
|
||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||
|
||||
@@ -63,8 +62,6 @@ class TagVersionsController < ApplicationController
|
||||
event_type: row.event_type,
|
||||
name: { current: row.name, prev: row.attributes['prev_name'] },
|
||||
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),
|
||||
parent_tags:,
|
||||
created_at: row.created_at.iso8601,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'set'
|
||||
|
||||
|
||||
class TagsController < ApplicationController
|
||||
@@ -15,8 +14,6 @@ class TagsController < ApplicationController
|
||||
post_count_between[1] = nil if post_count_between[1] < 0
|
||||
created_between = params[:created_from].presence, params[:created_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)
|
||||
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
||||
@@ -51,9 +48,6 @@ class TagsController < ApplicationController
|
||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||
if deprecated_given
|
||||
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
||||
end
|
||||
|
||||
sort_sql =
|
||||
case order[0]
|
||||
@@ -82,20 +76,39 @@ class TagsController < ApplicationController
|
||||
def with_depth
|
||||
parent_tag_id = params[:parent].to_i
|
||||
parent_tag_id = nil if parent_tag_id <= 0
|
||||
material_filter = material_filter_param(default: 'any')
|
||||
|
||||
graph = build_with_depth_graph(material_filter)
|
||||
|
||||
tag_ids =
|
||||
if parent_tag_id
|
||||
visible_child_tag_ids(parent_tag_id, graph)
|
||||
TagImplication.where(parent_tag_id:).select(:tag_id)
|
||||
else
|
||||
visible_root_tag_ids(graph)
|
||||
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
||||
end
|
||||
|
||||
render json: tag_ids
|
||||
.sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
|
||||
.map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
|
||||
tags =
|
||||
Tag
|
||||
.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.where(category: [:meme, :character, :material])
|
||||
.where(id: tag_ids)
|
||||
.order('tag_names.name')
|
||||
.distinct
|
||||
.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|
|
||||
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
||||
}
|
||||
end
|
||||
|
||||
def autocomplete
|
||||
@@ -120,7 +133,6 @@ class TagsController < ApplicationController
|
||||
|
||||
base = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.where(deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
|
||||
canonical_hit =
|
||||
@@ -220,16 +232,13 @@ class TagsController < ApplicationController
|
||||
def materials_by_name
|
||||
name = params[:name].to_s.strip
|
||||
return render_bad_request('name は必須です.') if name.blank?
|
||||
material_filter = material_filter_param(default: 'any')
|
||||
|
||||
tag = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.find_by(tag_names: { name: })
|
||||
return head :not_found unless tag
|
||||
|
||||
graph = build_with_depth_graph(material_filter)
|
||||
|
||||
render json: build_tag_children(tag, graph:)
|
||||
render json: build_tag_children(tag)
|
||||
end
|
||||
|
||||
def update_all
|
||||
@@ -243,24 +252,18 @@ class TagsController < ApplicationController
|
||||
category = params[:category].to_s.strip
|
||||
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
||||
|
||||
if (name != tag.name &&
|
||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
|
||||
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
||||
if name != tag.name &&
|
||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
||||
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name)
|
||||
end
|
||||
|
||||
if tag.nico? || category == 'nico'
|
||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||
end
|
||||
|
||||
alias_names = params[:aliases].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
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
@@ -269,11 +272,7 @@ class TagsController < ApplicationController
|
||||
name_changed = name != old_name
|
||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||
|
||||
if tag.deprecated? == deprecated
|
||||
tag.update!(category:)
|
||||
else
|
||||
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
||||
end
|
||||
tag.update!(category:)
|
||||
tag.tag_name.update!(name:)
|
||||
|
||||
alias_names << old_name if name_changed
|
||||
@@ -301,17 +300,11 @@ class TagsController < ApplicationController
|
||||
|
||||
name = params[:name].presence
|
||||
category = params[:category].presence
|
||||
deprecated_given = params.key?(:deprecated)
|
||||
deprecated = bool?(:deprecated)
|
||||
|
||||
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')
|
||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||
end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@@ -323,9 +316,6 @@ class TagsController < ApplicationController
|
||||
|
||||
tag.tag_name.update!(name:) if name.present?
|
||||
tag.update!(category:) if category.present?
|
||||
if deprecated_given && tag.deprecated? != deprecated
|
||||
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
||||
end
|
||||
|
||||
tag.reload
|
||||
|
||||
@@ -342,170 +332,18 @@ class TagsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def material_filter_param default:
|
||||
value = params[:material_filter].to_s.presence
|
||||
return default unless ['present', 'missing', 'any'].include?(value)
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def build_with_depth_graph material_filter
|
||||
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
|
||||
|
||||
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
|
||||
|
||||
tags_by_id = Tag.joins(:tag_name)
|
||||
.where(id: tag_ids)
|
||||
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
||||
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
|
||||
h[id] = { name:, category:, deprecated: deprecated_at.present?,
|
||||
has_material: material_tag_ids.include?(id) }
|
||||
end
|
||||
|
||||
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
||||
visible_child_tag_ids_by_parent_id: { },
|
||||
visible_subtree_by_tag_id: { },
|
||||
material_filter: }
|
||||
end
|
||||
|
||||
def visible_root_tag_ids graph
|
||||
graph[:tags_by_id].filter_map do |tag_id, _attrs|
|
||||
next unless visible_root_tag?(tag_id, graph)
|
||||
next unless visible_subtree?(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
|
||||
|
||||
if with_depth_visible_tag?(tag, graph[:material_filter])
|
||||
visible_ids << tag_id
|
||||
return
|
||||
end
|
||||
|
||||
visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||
visible_subtree?(child_tag_id, graph)
|
||||
}
|
||||
end
|
||||
|
||||
def with_depth_visible_tag? tag, material_filter
|
||||
return false unless tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
||||
|
||||
case material_filter
|
||||
when 'present'
|
||||
tag[:has_material]
|
||||
when 'missing'
|
||||
!tag[:has_material]
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def visible_subtree? tag_id, graph
|
||||
cache = graph[:visible_subtree_by_tag_id]
|
||||
return cache[tag_id] if cache.key?(tag_id)
|
||||
|
||||
tag = graph[:tags_by_id][tag_id]
|
||||
return cache[tag_id] = false unless tag
|
||||
|
||||
if tag[:deprecated]
|
||||
return cache[tag_id] =
|
||||
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||
visible_subtree?(child_tag_id, graph)
|
||||
}
|
||||
end
|
||||
|
||||
cache[tag_id] =
|
||||
with_depth_visible_tag?(tag, graph[:material_filter]) ||
|
||||
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||
visible_subtree?(child_tag_id, graph)
|
||||
}
|
||||
end
|
||||
|
||||
def with_depth_lightweight_repr tag_id, graph
|
||||
tag = graph[:tags_by_id][tag_id]
|
||||
|
||||
{ id: tag_id,
|
||||
name: tag[:name],
|
||||
category: tag[:category],
|
||||
deprecated: tag[:deprecated],
|
||||
has_material: tag[:has_material],
|
||||
has_children: visible_child_tag_ids(tag_id, graph).present?,
|
||||
children: [] }
|
||||
end
|
||||
|
||||
def build_tag_children tag, graph: nil
|
||||
def build_tag_children tag
|
||||
material = tag.materials.first
|
||||
tag_graph = graph && graph[:tags_by_id][tag.id]
|
||||
material =
|
||||
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
|
||||
|
||||
children = tag.children.sort_by(&:name)
|
||||
if graph
|
||||
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
|
||||
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(
|
||||
children: children.map { build_tag_children(_1, graph:) },
|
||||
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
|
||||
material: material && MaterialRepr.base(material, host: request.base_url))
|
||||
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
||||
material: material.as_json&.merge(file:, content_type:))
|
||||
end
|
||||
|
||||
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
|
||||
|
||||
@@ -4,18 +4,17 @@ class WikiPagesController < ApplicationController
|
||||
def index
|
||||
title = params[:title].to_s.strip
|
||||
if title.blank?
|
||||
return render json: WikiPageRepr.base(
|
||||
WikiPage.joins(:tag_name).includes(tag_name: :tag))
|
||||
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
|
||||
end
|
||||
|
||||
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
|
||||
q = WikiPage.joins(:tag_name).includes(:tag_name)
|
||||
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
||||
render json: WikiPageRepr.base(q.limit(20))
|
||||
end
|
||||
|
||||
def show
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.includes(:tag_name)
|
||||
.find_by(id: params[:id])
|
||||
render_wiki_page_or_404 page
|
||||
end
|
||||
@@ -23,7 +22,7 @@ class WikiPagesController < ApplicationController
|
||||
def show_by_title
|
||||
title = params[:title].to_s.strip
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.includes(:tag_name)
|
||||
.find_by(tag_name: { name: title })
|
||||
render_wiki_page_or_404 page
|
||||
end
|
||||
@@ -52,7 +51,7 @@ class WikiPagesController < ApplicationController
|
||||
from = params[:from].presence
|
||||
to = params[:to].presence
|
||||
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id)
|
||||
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
@@ -77,7 +76,6 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
render json: { wiki_page_id: page.id,
|
||||
title: page.title,
|
||||
deprecated_at: page.deprecated_at,
|
||||
older_revision_id: from_rev&.id,
|
||||
newer_revision_id: to_rev.id,
|
||||
diff: diff_json }
|
||||
@@ -159,7 +157,7 @@ class WikiPagesController < ApplicationController
|
||||
def changes
|
||||
id = params[:id].presence
|
||||
q = WikiRevision.joins(wiki_page: :tag_name)
|
||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||
.includes(:created_user, wiki_page: :tag_name)
|
||||
.order(id: :desc)
|
||||
q = q.where(wiki_page_id: id) if id
|
||||
|
||||
@@ -167,9 +165,7 @@ class WikiPagesController < ApplicationController
|
||||
{ revision_id: rev.id,
|
||||
pred: rev.base_revision_id,
|
||||
succ: nil,
|
||||
wiki_page: { id: rev.wiki_page_id,
|
||||
title: rev.wiki_page.title,
|
||||
deprecated_at: rev.wiki_page.deprecated_at },
|
||||
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title },
|
||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||
kind: rev.kind,
|
||||
message: rev.message,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
class GekanatorAiRun < ApplicationRecord
|
||||
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
|
||||
|
||||
belongs_to :gekanator_question_suggestion
|
||||
|
||||
validates :model, presence: true, length: { maximum: 255 }
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
validates :input_tokens,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :output_tokens,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :estimated_cost_jpy,
|
||||
presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
scope :this_month, lambda {
|
||||
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
|
||||
}
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
class GekanatorGame < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :guessed_post, class_name: 'Post'
|
||||
belongs_to :correct_post, class_name: 'Post'
|
||||
has_many :question_suggestions,
|
||||
class_name: 'GekanatorQuestionSuggestion',
|
||||
dependent: :delete_all
|
||||
has_many :question_examples,
|
||||
class_name: 'GekanatorQuestionExample',
|
||||
dependent: :delete_all
|
||||
|
||||
validates :answers, presence: true
|
||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :won, inclusion: { in: [true, false] }
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
class GekanatorQuestion < ApplicationRecord
|
||||
KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
|
||||
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
|
||||
STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
|
||||
|
||||
belongs_to :gekanator_question_suggestion, optional: true
|
||||
belongs_to :created_by, class_name: 'User', optional: true
|
||||
has_many :gekanator_question_examples, dependent: :delete_all
|
||||
|
||||
validates :kind, presence: true, inclusion: { in: KINDS }
|
||||
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
validates :text, presence: true, length: { maximum: 1000 }
|
||||
validates :condition, presence: true
|
||||
validates :priority_weight,
|
||||
presence: true,
|
||||
numericality: {
|
||||
greater_than: 0,
|
||||
less_than_or_equal_to: 3
|
||||
}
|
||||
|
||||
scope :accepted, -> { where(status: 'accepted') }
|
||||
end
|
||||
@@ -1,97 +0,0 @@
|
||||
class GekanatorQuestionExample < ApplicationRecord
|
||||
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
|
||||
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
|
||||
SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze
|
||||
|
||||
belongs_to :gekanator_question
|
||||
belongs_to :post
|
||||
belongs_to :user
|
||||
belongs_to :gekanator_game, optional: true
|
||||
|
||||
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||
validates :answer_counts, presence: true
|
||||
validates :sample_count,
|
||||
presence: true,
|
||||
numericality: {
|
||||
only_integer: true,
|
||||
greater_than: 0
|
||||
}
|
||||
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||
validates :weight,
|
||||
presence: true,
|
||||
numericality: {
|
||||
greater_than: 0
|
||||
}
|
||||
|
||||
before_validation :normalize_learning_state
|
||||
|
||||
def record_answer!(answer:, source:, gekanator_game: nil)
|
||||
answer = answer.to_s
|
||||
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
|
||||
|
||||
counts = normalized_answer_counts
|
||||
counts[answer] += 1
|
||||
|
||||
self.answer_counts = counts
|
||||
self.sample_count = counts.values.sum
|
||||
self.gekanator_game = gekanator_game if gekanator_game.present?
|
||||
self.source = source
|
||||
|
||||
apply_aggregated_answer!(preferred_answer: answer)
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_learning_state
|
||||
counts = normalized_answer_counts
|
||||
|
||||
if counts.values.sum.zero? && answer.present?
|
||||
counts[answer] = 1
|
||||
end
|
||||
|
||||
self.answer_counts = counts
|
||||
self.sample_count = counts.values.sum
|
||||
|
||||
apply_aggregated_answer!
|
||||
end
|
||||
|
||||
def apply_aggregated_answer!(preferred_answer: nil)
|
||||
counts = normalized_answer_counts
|
||||
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
|
||||
known_total = known_counts.values.sum
|
||||
|
||||
if known_total.zero?
|
||||
self.answer = 'unknown'
|
||||
self.weight = 0.1
|
||||
return
|
||||
else
|
||||
max_count = known_counts.values.max
|
||||
candidates = known_counts.select { |_answer, count| count == max_count }.keys
|
||||
self.answer =
|
||||
if preferred_answer.present? && candidates.include?(preferred_answer)
|
||||
preferred_answer
|
||||
elsif answer.present? && candidates.include?(answer)
|
||||
answer
|
||||
else
|
||||
candidates.first
|
||||
end
|
||||
end
|
||||
|
||||
consensus = max_count.to_f / known_total
|
||||
self.weight = Math.sqrt(known_total) * consensus
|
||||
end
|
||||
|
||||
def normalized_answer_counts
|
||||
base = ANSWERS.index_with(0)
|
||||
|
||||
answer_counts.to_h.each do |key, value|
|
||||
answer_key = key.to_s
|
||||
next unless ANSWERS.include?(answer_key)
|
||||
|
||||
base[answer_key] = value.to_i
|
||||
end
|
||||
|
||||
base
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
|
||||
|
||||
belongs_to :gekanator_game
|
||||
belongs_to :user
|
||||
has_many :gekanator_questions, dependent: :nullify
|
||||
has_many :gekanator_ai_runs, dependent: :destroy
|
||||
|
||||
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||
validates :processed, inclusion: { in: [true, false] }
|
||||
end
|
||||
@@ -9,13 +9,8 @@ class Material < ApplicationRecord
|
||||
belongs_to :tag, optional: true
|
||||
belongs_to :created_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 :thumbnail, dependent: :purge
|
||||
|
||||
validates :tag_id, presence: true, uniqueness: true
|
||||
|
||||
@@ -23,30 +18,11 @@ class Material < ApplicationRecord
|
||||
validate :tag_must_be_material_category
|
||||
|
||||
def content_type
|
||||
return nil if file_suppressed?
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.content_type
|
||||
end
|
||||
|
||||
def file_byte_size
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.byte_size
|
||||
end
|
||||
|
||||
def file_filename
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.filename.to_s
|
||||
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
|
||||
|
||||
def file_must_be_attached
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
class MaterialVersion < ApplicationRecord
|
||||
EVENT_TYPE_MAP = { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore',
|
||||
suppress: 'suppress' }.freeze
|
||||
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :material
|
||||
belongs_to :tag, optional: true
|
||||
belongs_to :parent, class_name: 'Material', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
def export_paths_hash
|
||||
export_paths_json || {}
|
||||
end
|
||||
end
|
||||
@@ -7,23 +7,10 @@ class Post < ApplicationRecord
|
||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||
has_many :tags, through: :active_post_tags
|
||||
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
||||
through: :active_post_tags, source: :tag
|
||||
|
||||
has_many :user_post_views, dependent: :delete_all
|
||||
has_many :post_similarities, dependent: :delete_all
|
||||
has_many :post_versions
|
||||
has_many :gekanator_guessed_games,
|
||||
class_name: 'GekanatorGame',
|
||||
foreign_key: :guessed_post_id,
|
||||
dependent: :delete_all,
|
||||
inverse_of: :guessed_post
|
||||
has_many :gekanator_correct_games,
|
||||
class_name: 'GekanatorGame',
|
||||
foreign_key: :correct_post_id,
|
||||
dependent: :delete_all,
|
||||
inverse_of: :correct_post
|
||||
has_many :gekanator_question_examples, dependent: :delete_all
|
||||
|
||||
has_many :parent_post_implications,
|
||||
class_name: 'PostImplication',
|
||||
|
||||
+1
-24
@@ -8,15 +8,6 @@ class Tag < ApplicationRecord
|
||||
;
|
||||
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 :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||
@@ -67,7 +58,6 @@ class Tag < ApplicationRecord
|
||||
validate :nico_tag_name_must_start_with_nico
|
||||
validate :tag_name_must_be_canonical
|
||||
validate :category_must_be_deerjikist_with_deerjikists
|
||||
validate :nico_tags_cannot_be_deprecated
|
||||
|
||||
scope :nico_tags, -> { nico }
|
||||
|
||||
@@ -87,8 +77,6 @@ class Tag < ApplicationRecord
|
||||
(self.tag_name ||= build_tag_name).name = val
|
||||
end
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
|
||||
def material_id = materials.first&.id
|
||||
@@ -104,8 +92,7 @@ class Tag < ApplicationRecord
|
||||
|
||||
def self.normalise_tags! tag_names, with_tagme: true,
|
||||
with_no_deerjikist: true,
|
||||
deny_nico: true,
|
||||
deny_deprecated: false
|
||||
deny_nico: true
|
||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
raise NicoTagNormalisationError
|
||||
end
|
||||
@@ -114,10 +101,6 @@ class Tag < ApplicationRecord
|
||||
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
||||
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -245,10 +228,4 @@ class Tag < ApplicationRecord
|
||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||
end
|
||||
end
|
||||
|
||||
def nico_tags_cannot_be_deprecated
|
||||
if nico? && deprecated_at.present?
|
||||
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
module VersionRecord
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore' }.freeze
|
||||
|
||||
def readonly? = persisted?
|
||||
|
||||
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
|
||||
|
||||
enum :event_type, event_type_map, prefix: true, validate: true
|
||||
enum :event_type, { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore' }, prefix: true, validate: true
|
||||
|
||||
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :event_type, presence: true
|
||||
|
||||
@@ -22,7 +22,6 @@ class WikiPage < ApplicationRecord
|
||||
validates :body, presence: true
|
||||
|
||||
def title = tag_name.name
|
||||
def deprecated_at = tag_name.tag&.deprecated_at
|
||||
|
||||
def title= val
|
||||
(self.tag_name ||= build_tag_name).name = val
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
|
||||
module MaterialRepr
|
||||
BASE = { only: [:id, :url, :version_no, :file_suppressed_at,
|
||||
:file_suppression_reason, :created_at, :updated_at],
|
||||
BASE = { only: [:id, :url, :created_at, :updated_at],
|
||||
methods: [:content_type],
|
||||
include: { tag: TagRepr::BASE,
|
||||
created_by_user: UserRepr::BASE,
|
||||
@@ -13,94 +12,13 @@ module MaterialRepr
|
||||
|
||||
def base material, host:
|
||||
material.as_json(BASE).merge(
|
||||
file: if material.file.attached? && !material.file_suppressed?
|
||||
file: if material.file.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
material.file, host:)
|
||||
end,
|
||||
thumbnail: thumbnail_url(material, host:),
|
||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||
media_kind: media_kind(material),
|
||||
file_byte_size: material.file_byte_size,
|
||||
export_paths: export_paths(material),
|
||||
export_items: export_items(material))
|
||||
end)
|
||||
end
|
||||
|
||||
def many materials, host:
|
||||
materials.map { |m| base(m, host:) }
|
||||
end
|
||||
|
||||
def list material, host:
|
||||
{ id: material.id,
|
||||
version_no: material.version_no,
|
||||
url: material.url,
|
||||
tag: compact_tag(material.tag),
|
||||
thumbnail: thumbnail_url(material, host:),
|
||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||
media_kind: media_kind(material),
|
||||
content_type: material.content_type,
|
||||
file_byte_size: material.file_byte_size,
|
||||
file_suppressed_at: material.file_suppressed_at,
|
||||
created_at: material.created_at,
|
||||
updated_at: material.updated_at,
|
||||
export_paths: export_paths(material),
|
||||
export_items: export_items(material) }
|
||||
end
|
||||
|
||||
def list_many materials, host:
|
||||
materials.map { |m| list(m, host:) }
|
||||
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
|
||||
|
||||
def thumbnail_url material, host:
|
||||
return nil if material.file_suppressed?
|
||||
return nil unless material.thumbnail.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
material.thumbnail, host:)
|
||||
end
|
||||
|
||||
def thumbnail_fallback_text material
|
||||
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
|
||||
end
|
||||
|
||||
def thumbnail_fallback_kind material
|
||||
material.tag.present? ? 'tag_name' : 'created_at'
|
||||
end
|
||||
|
||||
def media_kind material
|
||||
return 'suppressed' if material.file_suppressed?
|
||||
return 'url_only' unless material.file.attached?
|
||||
|
||||
content_type = material.file.blob.content_type.to_s
|
||||
return 'image' if content_type.start_with?('image/')
|
||||
return 'video' if content_type.start_with?('video/')
|
||||
return 'audio' if content_type.start_with?('audio/')
|
||||
|
||||
'file_other'
|
||||
end
|
||||
|
||||
def compact_tag tag
|
||||
return nil unless tag
|
||||
|
||||
{ id: tag.id,
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
deprecated_at: tag.deprecated_at }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ module PostRepr
|
||||
end
|
||||
|
||||
def tag_json tags
|
||||
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
|
||||
tags.map { |tag| TagRepr.inline(tag) }
|
||||
end
|
||||
|
||||
def thumbnail_url post
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
||||
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
module WikiPageRepr
|
||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
||||
BASE = { methods: [:title] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module Gekanator
|
||||
class AiRunBudget
|
||||
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
|
||||
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
|
||||
|
||||
def self.remaining_monthly_budget_jpy
|
||||
MONTHLY_LIMIT_JPY - monthly_cost_jpy
|
||||
end
|
||||
|
||||
def self.monthly_cost_jpy
|
||||
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
|
||||
end
|
||||
|
||||
def self.exceeded?
|
||||
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
|
||||
end
|
||||
|
||||
def self.exceeded_after_next_run?
|
||||
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,168 +0,0 @@
|
||||
module Gekanator
|
||||
class QuestionSuggestionAiConverter
|
||||
# Temporary heuristic converter for #361.
|
||||
# This creates pending ai_generated questions without external LLM calls;
|
||||
# accepted questions are still distributed only after admin approval.
|
||||
TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/
|
||||
ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/
|
||||
ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/
|
||||
ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/
|
||||
TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/
|
||||
SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/
|
||||
|
||||
def self.call(...) = new(...).call
|
||||
|
||||
def initialize suggestion:, user:
|
||||
@suggestion = suggestion
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
suggestion.with_lock do
|
||||
existing = existing_generated_question
|
||||
return existing if existing
|
||||
|
||||
run = suggestion.gekanator_ai_runs.create!(
|
||||
model: 'heuristic_converter_v1',
|
||||
status: 'running',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
estimated_cost_jpy: 0)
|
||||
|
||||
question_attributes = build_question
|
||||
question =
|
||||
question_attributes &&
|
||||
GekanatorQuestion.create!(
|
||||
**question_attributes,
|
||||
source: 'ai_generated',
|
||||
status: 'pending',
|
||||
gekanator_question_suggestion: suggestion,
|
||||
created_by: user)
|
||||
|
||||
run.update!(status: question ? 'succeeded' : 'failed')
|
||||
question
|
||||
end
|
||||
rescue => error
|
||||
run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed'
|
||||
raise error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :suggestion, :user
|
||||
|
||||
def existing_generated_question
|
||||
suggestion
|
||||
.gekanator_questions
|
||||
.where(source: 'ai_generated')
|
||||
.order(id: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def build_question
|
||||
text = normalized_text
|
||||
return nil if text.blank?
|
||||
|
||||
structured_question_for(text) || post_similarity_question_for(text)
|
||||
end
|
||||
|
||||
def normalized_text
|
||||
suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip
|
||||
end
|
||||
|
||||
def structured_question_for text
|
||||
case text
|
||||
when TITLE_LENGTH_RE
|
||||
length = Regexp.last_match(1).to_i
|
||||
return nil if length <= 0
|
||||
|
||||
{
|
||||
text:,
|
||||
kind: 'title',
|
||||
condition: {
|
||||
type: 'title-length-at-least',
|
||||
length:
|
||||
},
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when /\A題名に英数字が混じって[ゐい]る[??]\z/
|
||||
{
|
||||
text: '題名に英数字が混じってゐる?',
|
||||
kind: 'title',
|
||||
condition: { type: 'title-has-ascii' },
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when ORIGINAL_YEAR_RE
|
||||
year = Regexp.last_match(1).to_i
|
||||
{
|
||||
text:,
|
||||
kind: 'original_date',
|
||||
condition: { type: 'original-year', year: },
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when ORIGINAL_MONTH_RE
|
||||
month = Regexp.last_match(1).to_i
|
||||
return nil unless month.between?(1, 12)
|
||||
|
||||
{
|
||||
text:,
|
||||
kind: 'original_date',
|
||||
condition: { type: 'original-month', month: },
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when ORIGINAL_MONTH_DAY_RE
|
||||
month = Regexp.last_match(1).to_i
|
||||
day = Regexp.last_match(2).to_i
|
||||
return nil unless month.between?(1, 12) && day.between?(1, 31)
|
||||
|
||||
{
|
||||
text:,
|
||||
kind: 'original_date',
|
||||
condition: {
|
||||
type: 'original-month-day',
|
||||
monthDay: "#{ month }-#{ day }"
|
||||
},
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when TITLE_CONTAINS_RE
|
||||
title_text = Regexp.last_match(1).to_s.strip
|
||||
return nil if title_text.blank?
|
||||
|
||||
{
|
||||
text: "題名に「#{ title_text }」が含まれる?",
|
||||
kind: 'title',
|
||||
condition: { type: 'title-contains', text: title_text },
|
||||
priority_weight: 0.95
|
||||
}
|
||||
when SOURCE_RE
|
||||
host = Regexp.last_match(1).to_s.strip
|
||||
return nil if host.blank?
|
||||
|
||||
{
|
||||
text:,
|
||||
kind: 'source',
|
||||
condition: { type: 'source', host: },
|
||||
priority_weight: 0.95
|
||||
}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def post_similarity_question_for text
|
||||
return nil if suggestion.answer == 'unknown'
|
||||
|
||||
{
|
||||
text:,
|
||||
kind: 'post_similarity',
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: suggestion.gekanator_game.correct_post_id,
|
||||
answer: suggestion.answer,
|
||||
threshold: 0.65
|
||||
},
|
||||
priority_weight: 1.0
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,52 +0,0 @@
|
||||
module Gekanator
|
||||
class QuestionSuggestionPromoter
|
||||
def self.call(...) = new(...).call
|
||||
|
||||
def initialize suggestion:, user:
|
||||
@suggestion = suggestion
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
suggestion.with_lock do
|
||||
return promoted_question if suggestion.processed?
|
||||
return suggestion if suggestion.answer == 'unknown'
|
||||
|
||||
question = GekanatorQuestion.create!(
|
||||
text: suggestion.question_text,
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
status: 'accepted',
|
||||
priority_weight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: suggestion.gekanator_game.correct_post_id,
|
||||
answer: suggestion.answer,
|
||||
threshold: 0.65
|
||||
},
|
||||
gekanator_question_suggestion: suggestion,
|
||||
created_by: user)
|
||||
example =
|
||||
GekanatorQuestionExample.new(
|
||||
gekanator_question: question,
|
||||
post: suggestion.gekanator_game.correct_post,
|
||||
user: user)
|
||||
example.record_answer!(
|
||||
answer: suggestion.answer,
|
||||
source: 'initial_suggestion',
|
||||
gekanator_game: suggestion.gekanator_game)
|
||||
example.save!
|
||||
suggestion.update!(processed: true)
|
||||
question
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :suggestion, :user
|
||||
|
||||
def promoted_question
|
||||
suggestion.gekanator_questions.order(id: :desc).first
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
class MaterialImportBlockMatcher
|
||||
def self.match_for_sha256 sha256
|
||||
return nil if sha256.blank?
|
||||
|
||||
MaterialImportBlock.find_by(match_kind: 'sha256', sha256:)
|
||||
end
|
||||
end
|
||||
@@ -1,86 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'mini_magick'
|
||||
require 'open3'
|
||||
require 'tempfile'
|
||||
|
||||
|
||||
class MaterialThumbnailGenerator
|
||||
SIZE = '180x180'
|
||||
|
||||
class << self
|
||||
def generate! material
|
||||
new(material).generate!
|
||||
rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e
|
||||
Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def initialize material
|
||||
@material = material
|
||||
end
|
||||
|
||||
def generate!
|
||||
@material.thumbnail.purge if @material.thumbnail.attached?
|
||||
return unless @material.file.attached?
|
||||
return unless image? || video?
|
||||
|
||||
@material.file.blob.open do |file|
|
||||
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
|
||||
attach_thumbnail(thumbnail) if thumbnail
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image? = content_type.start_with?('image/')
|
||||
|
||||
def video? = content_type.start_with?('video/')
|
||||
|
||||
def content_type = @material.file.blob.content_type.to_s
|
||||
|
||||
def image_thumbnail path
|
||||
image = MiniMagick::Image.open(path)
|
||||
image.resize(SIZE)
|
||||
image.format('jpg')
|
||||
image
|
||||
end
|
||||
|
||||
def video_thumbnail path
|
||||
[1, 0].each do |seconds|
|
||||
tempfile = Tempfile.new(['material-thumbnail', '.jpg'])
|
||||
tempfile.close
|
||||
ok = extract_video_frame(path, tempfile.path, seconds)
|
||||
next unless ok && File.size?(tempfile.path)
|
||||
|
||||
return image_thumbnail(tempfile.path)
|
||||
ensure
|
||||
tempfile&.unlink
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_video_frame input_path, output_path, seconds
|
||||
_stdout, stderr, status =
|
||||
Open3.capture3('ffmpeg',
|
||||
'-y',
|
||||
'-ss', seconds.to_s,
|
||||
'-i', input_path,
|
||||
'-frames:v', '1',
|
||||
'-f', 'image2',
|
||||
output_path)
|
||||
Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success?
|
||||
status.success?
|
||||
rescue Errno::ENOENT => e
|
||||
Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }")
|
||||
false
|
||||
end
|
||||
|
||||
def attach_thumbnail image
|
||||
@material.thumbnail.attach(io: File.open(image.path),
|
||||
filename: 'material-thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
end
|
||||
end
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -1,149 +0,0 @@
|
||||
require 'stringio'
|
||||
require 'zlib'
|
||||
|
||||
# Initial implementation keeps every file payload and the final ZIP in memory.
|
||||
# Keep this service boundary stable so job/cached export paths can replace it later.
|
||||
class MaterialZipExporter
|
||||
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
|
||||
MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)
|
||||
|
||||
class EmptyExportError < StandardError; end
|
||||
class DuplicatePathError < StandardError; end
|
||||
class MissingFileError < StandardError
|
||||
attr_reader :missing_files
|
||||
|
||||
def initialize missing_files
|
||||
@missing_files = missing_files
|
||||
|
||||
super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
|
||||
end
|
||||
end
|
||||
|
||||
def initialize profile: 'legacy_drive', tag_id: nil
|
||||
@profile = profile.presence || 'legacy_drive'
|
||||
@tag_id = tag_id.presence
|
||||
end
|
||||
|
||||
def export
|
||||
entries = build_entries
|
||||
raise EmptyExportError if entries.empty?
|
||||
|
||||
ZipWriter.write(entries)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_entries
|
||||
rows = MaterialExportItem
|
||||
.enabled
|
||||
.includes(material: { file_attachment: :blob })
|
||||
.joins(:material)
|
||||
.merge(Material.kept)
|
||||
.where(profile: @profile)
|
||||
.where(materials: { file_suppressed_at: nil })
|
||||
.order(:export_path)
|
||||
|
||||
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
|
||||
|
||||
missing_files = []
|
||||
|
||||
entries = rows.filter_map do |item|
|
||||
material = item.material
|
||||
next unless material.file.attached?
|
||||
|
||||
data = download_blob(item, missing_files)
|
||||
next unless data
|
||||
|
||||
Entry.new(path: item.export_path,
|
||||
data:,
|
||||
mtime: material.updated_at || Time.current)
|
||||
end
|
||||
|
||||
raise MissingFileError.new(missing_files) if missing_files.any?
|
||||
|
||||
paths = entries.map(&:path)
|
||||
duplicated = paths.find { |path| paths.count(path) > 1 }
|
||||
raise DuplicatePathError, duplicated if duplicated
|
||||
|
||||
entries
|
||||
end
|
||||
|
||||
def download_blob item, missing_files
|
||||
blob = item.material.file.blob
|
||||
|
||||
blob.download
|
||||
rescue ActiveStorage::FileNotFoundError
|
||||
missing_files << MissingFile.new(
|
||||
material_id: item.material_id,
|
||||
export_path: item.export_path,
|
||||
blob_id: blob.id,
|
||||
filename: blob.filename.to_s,
|
||||
)
|
||||
nil
|
||||
end
|
||||
|
||||
class ZipWriter
|
||||
VERSION_NEEDED = 20
|
||||
GP_FLAG = 0x0800
|
||||
COMPRESSION_STORE = 0
|
||||
|
||||
def self.write entries
|
||||
new(entries).write
|
||||
end
|
||||
|
||||
def initialize entries
|
||||
@entries = entries
|
||||
@central_directory = []
|
||||
end
|
||||
|
||||
def write
|
||||
io = StringIO.new(''.b)
|
||||
|
||||
@entries.each do |entry|
|
||||
write_entry(io, entry)
|
||||
end
|
||||
|
||||
central_start = io.pos
|
||||
@central_directory.each { |header| io.write(header) }
|
||||
central_size = io.pos - central_start
|
||||
|
||||
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
|
||||
central_size, central_start, 0].pack('VvvvvVVv'))
|
||||
io.string
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_entry io, entry
|
||||
path = entry.path.b
|
||||
data = entry.data.b
|
||||
crc32 = Zlib.crc32(data)
|
||||
dos_time, dos_date = dos_timestamp(entry.mtime)
|
||||
offset = io.pos
|
||||
|
||||
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
||||
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
|
||||
path.bytesize, 0].pack('VvvvvvVVVvv')
|
||||
|
||||
io.write(local_header)
|
||||
io.write(path)
|
||||
io.write(data)
|
||||
|
||||
@central_directory << central_header(path:, crc32:, size: data.bytesize,
|
||||
dos_time:, dos_date:, offset:)
|
||||
end
|
||||
|
||||
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
|
||||
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
||||
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
|
||||
offset].pack('VvvvvvvVVVvvvvvVV') + path
|
||||
end
|
||||
|
||||
def dos_timestamp time
|
||||
local = time.to_time
|
||||
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
|
||||
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
|
||||
[dos_time, dos_date]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
module Similarity
|
||||
class Calc
|
||||
def self.call model, tgt, scope: nil
|
||||
def self.call model, tgt
|
||||
similarity_model = "#{ model.name }Similarity".constantize
|
||||
|
||||
# 最大保存件数
|
||||
@@ -8,8 +8,7 @@ module Similarity
|
||||
|
||||
similarity_model.delete_all
|
||||
|
||||
scope ||= model.all
|
||||
posts = scope.includes(tgt).select(:id).to_a
|
||||
posts = model.includes(tgt).select(:id).to_a
|
||||
|
||||
tag_ids = { }
|
||||
tag_cnts = { }
|
||||
|
||||
@@ -16,7 +16,6 @@ class TagVersionRecorder < VersionRecorder
|
||||
def snapshot_attributes
|
||||
{ name: @record.name,
|
||||
category: @record.category,
|
||||
deprecated_at: @record.deprecated_at,
|
||||
aliases: @record.snapshot_aliases.join(' '),
|
||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||
end
|
||||
|
||||
@@ -73,7 +73,7 @@ class VersionRecorder
|
||||
end
|
||||
|
||||
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 }"
|
||||
end
|
||||
@@ -84,5 +84,4 @@ class VersionRecorder
|
||||
def snapshot_attributes = raise NotImplementedError
|
||||
|
||||
def record_class = @record.class
|
||||
def event_types = self.class::EVENT_TYPES
|
||||
end
|
||||
|
||||
+1
-24
@@ -63,24 +63,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :gekanator do
|
||||
resources :games, only: [:create], controller: '/gekanator_games' do
|
||||
member do
|
||||
get :extra_questions
|
||||
post :extra_question_answers
|
||||
end
|
||||
end
|
||||
resources :posts, only: [:index], controller: '/gekanator_posts'
|
||||
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||
resources :question_suggestions,
|
||||
only: [:create],
|
||||
controller: '/gekanator_question_suggestions' do
|
||||
member do
|
||||
post :ai_convert
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :users, only: [:create, :update] do
|
||||
collection do
|
||||
post :verify
|
||||
@@ -113,10 +95,5 @@ Rails.application.routes.draw do
|
||||
resources :skip_events, controller: :theatre_skip_events, only: [:index]
|
||||
end
|
||||
|
||||
get 'materials/download.zip', to: 'materials#download'
|
||||
resources :materials, only: [:index, :show, :create, :update, :destroy] do
|
||||
member do
|
||||
patch :suppress_file
|
||||
end
|
||||
end
|
||||
resources :materials, only: [:index, :show, :create, :update, :destroy]
|
||||
end
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_games do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :guessed_post, null: false, foreign_key: { to_table: :posts }
|
||||
t.references :correct_post, null: false, foreign_key: { to_table: :posts }
|
||||
t.boolean :won, null: false
|
||||
t.integer :question_count, null: false
|
||||
t.json :answers, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_check_constraint :gekanator_games,
|
||||
'question_count >= 0',
|
||||
name: 'chk_gekanator_games_question_count_nonnegative'
|
||||
end
|
||||
end
|
||||
@@ -1,14 +0,0 @@
|
||||
class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_question_suggestions do |t|
|
||||
t.references :gekanator_game,
|
||||
null: false,
|
||||
foreign_key: { on_delete: :cascade }
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.text :question_text, null: false
|
||||
t.boolean :processed, null: false, default: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :gekanator_question_suggestions, :answer, :string, null: false
|
||||
end
|
||||
end
|
||||
@@ -1,19 +0,0 @@
|
||||
class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_questions do |t|
|
||||
t.string :text, null: false
|
||||
t.string :kind, null: false
|
||||
t.json :condition, null: false
|
||||
t.string :source, null: false, default: 'ai_generated'
|
||||
t.string :status, null: false, default: 'pending'
|
||||
t.float :priority_weight, null: false, default: 1.0
|
||||
t.references :gekanator_question_suggestion,
|
||||
null: true,
|
||||
foreign_key: true
|
||||
t.references :created_by,
|
||||
null: true,
|
||||
foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_ai_runs do |t|
|
||||
t.string :model, null: false
|
||||
t.integer :input_tokens, null: false, default: 0
|
||||
t.integer :output_tokens, null: false, default: 0
|
||||
t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
|
||||
t.string :status, null: false, default: 'pending'
|
||||
t.references :gekanator_question_suggestion, null: false, foreign_key: true
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,19 +0,0 @@
|
||||
class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_question_examples do |t|
|
||||
t.references :gekanator_question, null: false, foreign_key: true
|
||||
t.references :post, null: false, foreign_key: true
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :gekanator_game, null: true, foreign_key: true
|
||||
t.string :answer, null: false
|
||||
t.string :source, null: false, default: 'post_game_extra'
|
||||
t.float :weight, null: false, default: 1.0
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :gekanator_question_examples,
|
||||
[:gekanator_question_id, :post_id, :user_id],
|
||||
unique: true,
|
||||
name: 'idx_gekanator_question_examples_on_question_post_user'
|
||||
end
|
||||
end
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
|
||||
class MigrationGekanatorQuestionExample < ApplicationRecord
|
||||
self.table_name = 'gekanator_question_examples'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :gekanator_question_examples,
|
||||
:answer_counts,
|
||||
:json,
|
||||
null: true
|
||||
add_column :gekanator_question_examples,
|
||||
:sample_count,
|
||||
:integer,
|
||||
null: false,
|
||||
default: 1
|
||||
|
||||
MigrationGekanatorQuestionExample.reset_column_information
|
||||
MigrationGekanatorQuestionExample.find_each do |example|
|
||||
counts = {
|
||||
'yes' => 0,
|
||||
'no' => 0,
|
||||
'partial' => 0,
|
||||
'probably_no' => 0,
|
||||
'unknown' => 0
|
||||
}
|
||||
counts[example.answer] = 1 if counts.key?(example.answer)
|
||||
|
||||
example.update_columns(
|
||||
answer_counts: counts,
|
||||
sample_count: 1)
|
||||
end
|
||||
|
||||
change_column_null :gekanator_question_examples, :answer_counts, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :gekanator_question_examples, :sample_count
|
||||
remove_column :gekanator_question_examples, :answer_counts
|
||||
end
|
||||
end
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,98 +0,0 @@
|
||||
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
|
||||
生成ファイル
+1
-139
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -48,79 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "model", null: false
|
||||
t.integer "input_tokens", default: 0, null: false
|
||||
t.integer "output_tokens", default: 0, null: false
|
||||
t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.bigint "gekanator_question_suggestion_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "guessed_post_id", null: false
|
||||
t.bigint "correct_post_id", null: false
|
||||
t.boolean "won", null: false
|
||||
t.integer "question_count", null: false
|
||||
t.json "answers", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
|
||||
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
||||
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
|
||||
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||
end
|
||||
|
||||
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "gekanator_question_id", null: false
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "gekanator_game_id"
|
||||
t.string "answer", null: false
|
||||
t.string "source", default: "post_game_extra", null: false
|
||||
t.float "weight", default: 1.0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.json "answer_counts", null: false
|
||||
t.integer "sample_count", default: 1, null: false
|
||||
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
|
||||
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
|
||||
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
|
||||
t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id"
|
||||
t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "gekanator_game_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.text "question_text", null: false
|
||||
t.boolean "processed", default: false, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "answer", null: false
|
||||
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
|
||||
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "text", null: false
|
||||
t.string "kind", null: false
|
||||
t.json "condition", null: false
|
||||
t.string "source", default: "ai_generated", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.float "priority_weight", default: 1.0, null: false
|
||||
t.bigint "gekanator_question_suggestion_id"
|
||||
t.bigint "created_by_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
|
||||
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.binary "ip_address", limit: 16, null: false
|
||||
t.datetime "banned_at"
|
||||
@@ -130,32 +57,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
||||
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|
|
||||
t.bigint "material_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
@@ -167,28 +68,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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 ["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"], name: "index_material_versions_on_material_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 ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
|
||||
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
|
||||
|
||||
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -201,14 +88,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "discarded_at"
|
||||
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 ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
|
||||
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 ["tag_id"], name: "index_materials_on_tag_id"
|
||||
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
|
||||
@@ -364,7 +246,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.string "category", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.text "aliases", null: false
|
||||
t.text "parent_tag_ids", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -382,13 +263,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "discarded_at"
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||
end
|
||||
|
||||
@@ -600,21 +478,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||
add_foreign_key "gekanator_games", "users"
|
||||
add_foreign_key "gekanator_question_examples", "gekanator_games"
|
||||
add_foreign_key "gekanator_question_examples", "gekanator_questions"
|
||||
add_foreign_key "gekanator_question_examples", "posts"
|
||||
add_foreign_key "gekanator_question_examples", "users"
|
||||
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||
add_foreign_key "gekanator_question_suggestions", "users"
|
||||
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
||||
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
|
||||
add_foreign_key "material_export_items", "materials"
|
||||
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
|
||||
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
|
||||
add_foreign_key "material_versions", "materials"
|
||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||
add_foreign_key "material_versions", "tags"
|
||||
@@ -623,7 +486,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
|
||||
add_foreign_key "materials", "materials", column: "parent_id"
|
||||
add_foreign_key "materials", "tags"
|
||||
add_foreign_key "materials", "users", column: "created_by_user_id"
|
||||
add_foreign_key "materials", "users", column: "file_suppressed_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", column: "nico_tag_id"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace :post_similarity do
|
||||
desc '関聯投稿テーブル作成'
|
||||
task calc: :environment do
|
||||
Similarity::Calc.call(Post, :active_tags)
|
||||
Similarity::Calc.call(Post, :tags)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace :tag_similarity do
|
||||
desc '関聯タグ・テーブル作成'
|
||||
task calc: :environment do
|
||||
Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil))
|
||||
Similarity::Calc.call(Tag, :posts)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialExportItem, type: :model do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
|
||||
let(:material) do
|
||||
Material.create!(tag:, url: 'https://example.com/material',
|
||||
created_by_user: user, updated_by_user: user)
|
||||
end
|
||||
|
||||
it 'rejects blank export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects absolute export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '/素材/a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects parent traversal export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材/../a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects double slash export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材//a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects dot segment export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: './素材/a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects trailing slash export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材/a/')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagNameSanitisationRule, type: :model do
|
||||
before do
|
||||
described_class.unscoped.delete_all
|
||||
end
|
||||
|
||||
describe '.sanitise' do
|
||||
before do
|
||||
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
||||
|
||||
@@ -1,79 +1,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
let!(:target_tag) { create(:tag, category: :general) }
|
||||
let!(:source_tag) { create(:tag, category: :general) }
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Gekanator games API', type: :request do
|
||||
let!(:admin) { create_admin_user! }
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
||||
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
||||
|
||||
describe 'POST /gekanator/games' do
|
||||
it 'stores a won game' do
|
||||
sign_in_as admin
|
||||
|
||||
post '/gekanator/games', params: {
|
||||
guessed_post_id: guessed_post.id,
|
||||
correct_post_id: guessed_post.id,
|
||||
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
game = GekanatorGame.find(json['id'])
|
||||
expect(game.user).to eq(admin)
|
||||
expect(game.guessed_post).to eq(guessed_post)
|
||||
expect(game.correct_post).to eq(guessed_post)
|
||||
expect(game.won).to eq(true)
|
||||
expect(game.question_count).to eq(1)
|
||||
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
||||
end
|
||||
|
||||
it 'stores a lost game with the correct post' do
|
||||
sign_in_as admin
|
||||
|
||||
post '/gekanator/games', params: {
|
||||
guessed_post_id: guessed_post.id,
|
||||
correct_post_id: correct_post.id,
|
||||
question_count: 4,
|
||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
game = GekanatorGame.find(json['id'])
|
||||
expect(game.correct_post).to eq(correct_post)
|
||||
expect(game.won).to eq(false)
|
||||
expect(game.question_count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects a game without the correct post' do
|
||||
sign_in_as admin
|
||||
|
||||
post '/gekanator/games', params: {
|
||||
guessed_post_id: guessed_post.id,
|
||||
question_count: 4,
|
||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns unauthorized without a user' do
|
||||
post '/gekanator/games', params: {
|
||||
guessed_post_id: guessed_post.id,
|
||||
correct_post_id: guessed_post.id,
|
||||
answers: [] }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'stores a game for a non-admin user' do
|
||||
sign_in_as user
|
||||
|
||||
post '/gekanator/games', params: {
|
||||
guessed_post_id: guessed_post.id,
|
||||
correct_post_id: guessed_post.id,
|
||||
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(GekanatorGame.find(json['id']).user).to eq(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -1,33 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
|
||||
RSpec.describe 'Gekanator posts API', type: :request do
|
||||
describe 'GET /gekanator/posts' do
|
||||
it 'omits deprecated tags and returns the stored similarity cosine' do
|
||||
active_tag = Tag.create!(name: 'active tag', category: :general)
|
||||
deprecated_tag = Tag.create!(
|
||||
name: 'deprecated tag',
|
||||
category: :general,
|
||||
deprecated_at: Time.current
|
||||
)
|
||||
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
|
||||
target_post = Post.create!(title: 'target', url: 'https://example.com/target')
|
||||
|
||||
PostTag.create!(post: post_record, tag: active_tag)
|
||||
PostTag.create!(post: post_record, tag: deprecated_tag)
|
||||
PostTag.create!(post: target_post, tag: deprecated_tag)
|
||||
PostSimilarity.create!(post: post_record, target_post:, cos: 0.375)
|
||||
|
||||
get '/gekanator/posts'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
post_json = json.fetch('posts').find { |post| post.fetch('id') == post_record.id }
|
||||
expect(post_json.fetch('tags').map { |tag| tag.fetch('name') }).to eq(['active tag'])
|
||||
expect(post_json.fetch('post_similarity_edges')).to contain_exactly(
|
||||
'target_post_id' => target_post.id,
|
||||
'cos' => 0.375
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Materials API', type: :request do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let!(:member_user) { create(:user, :member) }
|
||||
let!(:admin_user) { create(:user, :admin) }
|
||||
let!(:guest_user) { create(:user) }
|
||||
|
||||
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
||||
@@ -16,29 +13,22 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
|
||||
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.save!
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /materials' do
|
||||
let!(:tag_a) do
|
||||
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!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
|
||||
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
|
||||
|
||||
let!(:material_a) do
|
||||
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
before do
|
||||
@@ -107,9 +97,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /materials/:id' do
|
||||
let!(:tag) do
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
|
||||
end
|
||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
|
||||
let!(:material) do
|
||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
|
||||
end
|
||||
@@ -150,22 +138,9 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when logged in but not member' do
|
||||
context 'when logged in' do
|
||||
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
|
||||
post '/materials', params: { tag: ' ', file: dummy_upload }
|
||||
|
||||
@@ -187,49 +162,24 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect do
|
||||
post '/materials', params: {
|
||||
tag: 'material_create_new',
|
||||
file: dummy_upload(filename: 'created.png'),
|
||||
export_paths: { legacy_drive: '伊地知ニジカ/created.png' }
|
||||
file: dummy_upload(filename: 'created.png')
|
||||
}
|
||||
end.to change(Material, :count).by(1)
|
||||
.and change(Tag, :count).by(1)
|
||||
.and change(TagName, :count).by(1)
|
||||
.and change(MaterialVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
material = Material.order(:id).last
|
||||
expect(material.tag.name).to eq('material_create_new')
|
||||
expect(material.tag.category).to eq('material')
|
||||
expect(material.created_by_user).to eq(member_user)
|
||||
expect(material.updated_by_user).to eq(member_user)
|
||||
expect(material.created_by_user).to eq(guest_user)
|
||||
expect(material.updated_by_user).to eq(guest_user)
|
||||
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.dig('tag', 'name')).to eq('material_create_new')
|
||||
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
|
||||
|
||||
it 'returns 422 when the existing tag is not material/character' do
|
||||
@@ -269,33 +219,11 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(json['url']).to eq('https://example.com/material-source')
|
||||
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
|
||||
|
||||
describe 'PUT /materials/:id' do
|
||||
let!(:tag) do
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
|
||||
end
|
||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
|
||||
let!(:material) do
|
||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
|
||||
end
|
||||
@@ -349,26 +277,25 @@ RSpec.describe 'Materials API', type: :request do
|
||||
'tag' => ['タグは必須です.'])
|
||||
end
|
||||
|
||||
it 'keeps the existing file when file and url are omitted' do
|
||||
it 'returns 422 when both file and url are blank' do
|
||||
put "/materials/#{ material.id }", params: {
|
||||
tag: 'material_update_no_payload'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(material.reload.file.attached?).to be(true)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'file' => ['ファイルまたは URL は必須です.'],
|
||||
'url' => ['ファイルまたは URL は必須です.'])
|
||||
end
|
||||
|
||||
it 'updates tag, url, file, and updated_by_user' do
|
||||
old_blob_id = material.file.blob.id
|
||||
|
||||
expect do
|
||||
put "/materials/#{ material.id }", params: {
|
||||
tag: 'material_update_new',
|
||||
url: 'https://example.com/updated-source',
|
||||
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'),
|
||||
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
|
||||
}
|
||||
end.to change(MaterialVersion, :count).by(2)
|
||||
put "/materials/#{ material.id }", params: {
|
||||
tag: 'material_update_new',
|
||||
url: 'https://example.com/updated-source',
|
||||
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
@@ -379,15 +306,8 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(material.updated_by_user).to eq(member_user)
|
||||
expect(material.file.attached?).to be(true)
|
||||
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.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['file']).to be_present
|
||||
@@ -395,7 +315,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
||||
end
|
||||
|
||||
it 'detaches the existing file without purging blob when url replaces file' do
|
||||
it 'purges the existing file when file is omitted and url is provided' do
|
||||
old_blob_id = material.file.blob.id
|
||||
|
||||
put "/materials/#{ material.id }", params: {
|
||||
@@ -411,7 +331,9 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(material.updated_by_user).to eq(member_user)
|
||||
expect(material.file.attached?).to be(false)
|
||||
|
||||
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
||||
expect(
|
||||
ActiveStorage::Blob.where(id: old_blob_id).exists?
|
||||
).to be(false)
|
||||
|
||||
expect(json['id']).to eq(material.id)
|
||||
expect(json['file']).to be_nil
|
||||
@@ -419,190 +341,11 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
|
||||
expect(json['url']).to eq('https://example.com/updated-source')
|
||||
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
|
||||
|
||||
describe 'DELETE /materials/:id' do
|
||||
let!(:tag) do
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
|
||||
end
|
||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
|
||||
let!(:material) do
|
||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
|
||||
end
|
||||
|
||||
@@ -517,24 +517,6 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect([true, false]).to include(json['viewed'])
|
||||
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
|
||||
let!(:parent_post) do
|
||||
create_parent_post!(
|
||||
@@ -715,58 +697,6 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(names).not_to include('manko')
|
||||
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
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
@@ -1000,26 +930,6 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(names).to include('spec_tag_2')
|
||||
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
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
|
||||
@@ -21,7 +21,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
event_type:,
|
||||
name:,
|
||||
category:,
|
||||
deprecated_at: nil,
|
||||
aliases: [],
|
||||
parent_tags: [],
|
||||
created_by_user:,
|
||||
@@ -34,7 +33,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
event_type: event_type,
|
||||
name: name,
|
||||
category: category,
|
||||
deprecated_at: deprecated_at,
|
||||
aliases: Array(aliases).join(' '),
|
||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||
created_by_user: created_by_user,
|
||||
@@ -67,7 +65,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
event_type: 'update',
|
||||
name: 'new_tag_name',
|
||||
category: 'meme',
|
||||
deprecated_at: t_v2,
|
||||
aliases: ['alias_shared', 'alias_new'],
|
||||
parent_tags: [parent_shared, parent_new],
|
||||
created_by_user: member,
|
||||
@@ -136,10 +133,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
'current' => 'meme',
|
||||
'prev' => 'general'
|
||||
)
|
||||
expect(latest.fetch('deprecated_at')).to eq(
|
||||
'current' => t_v2.iso8601,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('aliases')).to include(
|
||||
{ 'name' => 'alias_shared', 'type' => 'context' },
|
||||
{ 'name' => 'alias_new', 'type' => 'added' },
|
||||
@@ -185,10 +178,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
'current' => 'general',
|
||||
'prev' => nil
|
||||
)
|
||||
expect(first.fetch('deprecated_at')).to eq(
|
||||
'current' => nil,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(first.fetch('aliases')).to include(
|
||||
{ 'name' => 'alias_shared', 'type' => 'added' },
|
||||
{ 'name' => 'alias_old', 'type' => 'added' }
|
||||
|
||||
@@ -89,7 +89,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
category: 'general',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
@@ -124,7 +123,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
category: 'meme',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
@@ -151,7 +149,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'put_tag_alias_only_alias',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
|
||||
@@ -76,27 +76,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(response_tags.first['id']).to eq(meme.id)
|
||||
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
|
||||
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
||||
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
||||
@@ -322,21 +301,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(t['matched_alias']).to eq('unko')
|
||||
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
||||
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
|
||||
|
||||
describe 'GET /tags/name/:name' do
|
||||
@@ -473,32 +437,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||
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
|
||||
expect {
|
||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||
@@ -647,111 +585,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(row['has_children']).to eq(true)
|
||||
expect(row['children']).to eq([])
|
||||
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
|
||||
|
||||
describe 'GET /tags/name/:name/materials' do
|
||||
@@ -899,20 +732,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(tag.category).to eq('general')
|
||||
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
|
||||
old_parent = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'put_old_parent'),
|
||||
@@ -930,7 +749,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'meme',
|
||||
aliases: 'put_alias_a put_alias_b put_alias_a',
|
||||
parent_tags: 'put_kept_parent put_new_parent',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -975,7 +793,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'spec_tag put_alias_self_test',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -993,7 +810,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: 'spec_tag',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -1009,7 +825,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'meta',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}.to change(TagVersion, :count).by(2)
|
||||
|
||||
@@ -1045,7 +860,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: new_parent.name,
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -1061,7 +875,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'nico',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}.not_to change(TagVersion, :count)
|
||||
|
||||
@@ -1083,7 +896,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'nico',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
@@ -1104,7 +916,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: old_category,
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}.not_to change(TagVersion, :count)
|
||||
|
||||
@@ -1135,7 +946,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'meme',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
@@ -1171,7 +981,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'unko put_stolen_alias',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change { tag.reload.tag_versions.count }.by(2)
|
||||
@@ -1206,7 +1015,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: child.name,
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
@@ -1228,7 +1036,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
|
||||
@@ -18,13 +18,6 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
end
|
||||
let!(:tag) do
|
||||
Tag.create!(
|
||||
tag_name: tn,
|
||||
category: :general,
|
||||
deprecated_at: Time.zone.local(2026, 6, 1)
|
||||
)
|
||||
end
|
||||
|
||||
describe 'GET /wiki' do
|
||||
it 'returns wiki pages with title' do
|
||||
@@ -37,8 +30,6 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
expect(json[0]).to have_key('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
|
||||
|
||||
@@ -57,8 +48,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
expect(json).to include(
|
||||
'id' => page.id,
|
||||
'title' => 'spec_wiki_title',
|
||||
'deprecated_at' => tag.deprecated_at.iso8601(3))
|
||||
'title' => 'spec_wiki_title')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -419,11 +409,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
'kind' => 'content',
|
||||
'message' => 'r2'
|
||||
)
|
||||
expect(top['wiki_page']).to include(
|
||||
'id' => page.id,
|
||||
'title' => 'spec_wiki_title',
|
||||
'deprecated_at' => tag.deprecated_at.iso8601(3)
|
||||
)
|
||||
expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title')
|
||||
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
||||
expect(top).to have_key('timestamp')
|
||||
|
||||
@@ -493,7 +479,6 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
expect(json).to include(
|
||||
'wiki_page_id' => page.id,
|
||||
'title' => 'spec_wiki_title',
|
||||
'deprecated_at' => tag.deprecated_at.iso8601(3),
|
||||
'older_revision_id' => rev_a.id,
|
||||
'newer_revision_id' => rev_b.id
|
||||
)
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Gekanator::QuestionSuggestionAiConverter do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
||||
let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
||||
let(:game) do
|
||||
GekanatorGame.create!(
|
||||
user: user,
|
||||
guessed_post: guessed_post,
|
||||
correct_post: correct_post,
|
||||
won: false,
|
||||
question_count: 1,
|
||||
answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }]
|
||||
)
|
||||
end
|
||||
|
||||
def create_suggestion!(question_text:, answer: 'yes')
|
||||
GekanatorQuestionSuggestion.create!(
|
||||
gekanator_game: game,
|
||||
user: user,
|
||||
question_text: question_text,
|
||||
answer: answer
|
||||
)
|
||||
end
|
||||
|
||||
it 'converts title-contains suggestions to pending ai-generated questions' do
|
||||
suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?')
|
||||
|
||||
expect {
|
||||
described_class.call(suggestion: suggestion, user: user)
|
||||
}.to change { GekanatorQuestion.count }.by(1)
|
||||
.and change { GekanatorAiRun.count }.by(1)
|
||||
|
||||
question = GekanatorQuestion.last
|
||||
expect(question).to have_attributes(
|
||||
text: '題名に「結束バンド」が含まれる?',
|
||||
kind: 'title',
|
||||
source: 'ai_generated',
|
||||
status: 'pending',
|
||||
priority_weight: 0.95,
|
||||
gekanator_question_suggestion_id: suggestion.id,
|
||||
created_by_id: user.id
|
||||
)
|
||||
expect(question.condition).to include(
|
||||
'type' => 'title-contains',
|
||||
'text' => '結束バンド'
|
||||
)
|
||||
expect(GekanatorAiRun.last).to have_attributes(
|
||||
gekanator_question_suggestion_id: suggestion.id,
|
||||
model: 'heuristic_converter_v1',
|
||||
status: 'succeeded'
|
||||
)
|
||||
end
|
||||
|
||||
it 'converts concrete non-unknown suggestions to post-similarity questions' do
|
||||
suggestion = create_suggestion!(
|
||||
question_text: '喜多ちゃんが泣いてる?',
|
||||
answer: 'partial'
|
||||
)
|
||||
|
||||
question = described_class.call(suggestion: suggestion, user: user)
|
||||
|
||||
expect(question).to have_attributes(
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'ai_generated',
|
||||
status: 'pending',
|
||||
priority_weight: 1.0
|
||||
)
|
||||
expect(question.condition).to include(
|
||||
'type' => 'post-similarity',
|
||||
'postId' => correct_post.id,
|
||||
'answer' => 'partial',
|
||||
'threshold' => 0.65
|
||||
)
|
||||
end
|
||||
|
||||
it 'records a failed run when the suggestion cannot be converted' do
|
||||
suggestion = create_suggestion!(
|
||||
question_text: 'よく分からない質問?',
|
||||
answer: 'unknown'
|
||||
)
|
||||
|
||||
expect {
|
||||
expect(described_class.call(suggestion: suggestion, user: user)).to be_nil
|
||||
}.not_to change { GekanatorQuestion.count }
|
||||
|
||||
expect(GekanatorAiRun.last).to have_attributes(
|
||||
gekanator_question_suggestion_id: suggestion.id,
|
||||
status: 'failed'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an existing generated question without creating a duplicate run' do
|
||||
suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?')
|
||||
existing = GekanatorQuestion.create!(
|
||||
text: 'タイトルは 10 文字以上?',
|
||||
kind: 'title',
|
||||
source: 'ai_generated',
|
||||
status: 'pending',
|
||||
priority_weight: 0.95,
|
||||
condition: { type: 'title-length-at-least', length: 10 },
|
||||
gekanator_question_suggestion: suggestion,
|
||||
created_by: user
|
||||
)
|
||||
|
||||
expect {
|
||||
expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing)
|
||||
}.not_to change { GekanatorAiRun.count }
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,11 @@ require 'rails_helper'
|
||||
RSpec.describe 'post_similarity:calc' do
|
||||
include RakeTaskHelper
|
||||
|
||||
it 'calculates similarities from active tags only' do
|
||||
it 'calls Similarity::Calc with Post and :tags' do
|
||||
# 必要最低限のデータ
|
||||
t1 = Tag.create!(name: "t1")
|
||||
t2 = Tag.create!(name: "t2")
|
||||
t3 = Tag.create!(name: "t3")
|
||||
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||
|
||||
p1 = Post.create!(url: "https://example.com/1")
|
||||
p2 = Post.create!(url: "https://example.com/2")
|
||||
@@ -23,8 +22,6 @@ RSpec.describe 'post_similarity:calc' do
|
||||
PostTag.create!(post: p2, 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") }
|
||||
.to change { PostSimilarity.count }.from(0)
|
||||
@@ -32,6 +29,6 @@ RSpec.describe 'post_similarity:calc' do
|
||||
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)
|
||||
expect(ps_rev.cos).to eq(ps.cos)
|
||||
expect(ps.cos).to be_within(0.0001).of(0.5)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ require 'rails_helper'
|
||||
RSpec.describe 'tag_similarity:calc' do
|
||||
include RakeTaskHelper
|
||||
|
||||
it 'calculates similarities for active tags only' do
|
||||
it 'calls Similarity::Calc with Tag and :posts' do
|
||||
# 必要最低限のデータ
|
||||
t1 = Tag.create!(name: "t1")
|
||||
t2 = Tag.create!(name: "t2")
|
||||
t3 = Tag.create!(name: "t3")
|
||||
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||
|
||||
p1 = Post.create!(url: "https://example.com/1")
|
||||
p2 = Post.create!(url: "https://example.com/2")
|
||||
@@ -23,7 +22,6 @@ RSpec.describe 'tag_similarity:calc' do
|
||||
PostTag.create!(post: p2, tag: t3)
|
||||
|
||||
PostTag.create!(post: p3, tag: t3)
|
||||
PostTag.create!(post: p1, tag: deprecated_tag)
|
||||
|
||||
expect { run_rake_task("tag_similarity:calc") }
|
||||
.to change { TagSimilarity.count }.from(0)
|
||||
@@ -31,7 +29,6 @@ RSpec.describe 'tag_similarity:calc' do
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -33,10 +33,6 @@ npm run lint
|
||||
|
||||
If either command cannot be run or fails, report the exact command and failure.
|
||||
|
||||
Do not create, modify, or run tests unless the user explicitly asks for test
|
||||
work. When the user asks for tests, keep working and rerun them until they
|
||||
pass or the remaining failure is clearly blocked.
|
||||
|
||||
## TypeScript
|
||||
|
||||
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
||||
|
||||
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 559 KiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 146 KiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 1.2 MiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 188 KiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 201 KiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 196 KiB |
バイナリファイルは表示されません.
|
変更前 幅: | 高さ: | サイズ: 179 KiB |
+1
-3
@@ -18,7 +18,6 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
|
||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||
import MorePage from '@/pages/MorePage'
|
||||
import GekanatorPage from '@/pages/GekanatorPage'
|
||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
@@ -69,7 +68,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||
<Route index element={<MaterialListPage/>}/>
|
||||
<Route path="new" element={<MaterialNewPage/>}/>
|
||||
<Route path=":id" element ={<MaterialDetailPage user={user}/>}/>
|
||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
||||
</Route>
|
||||
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||
@@ -81,7 +80,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
<Route path="/tos" element={<TOSPage/>}/>
|
||||
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
||||
<Route path="/more" element={<MorePage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
import TagLink from '@/components/TagLink'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { materialsKeys } from '@/lib/queryKeys'
|
||||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
|
||||
|
||||
const FILTER_LABELS: Record<MaterialFilter, string> = {
|
||||
present: '素材あり',
|
||||
missing: '素材なし',
|
||||
any: 'すべて'}
|
||||
type TagWithDepth = Tag & {
|
||||
hasChildren: boolean
|
||||
children: TagWithDepth[] }
|
||||
|
||||
|
||||
const setChildrenById = (
|
||||
tags: MaterialSidebarTag[],
|
||||
tags: TagWithDepth[],
|
||||
targetId: number,
|
||||
children: MaterialSidebarTag[]): MaterialSidebarTag[] => (
|
||||
children: TagWithDepth[],
|
||||
): TagWithDepth[] => (
|
||||
tags.map (tag => {
|
||||
if (tag.id === targetId)
|
||||
return { ...tag, children }
|
||||
@@ -30,285 +25,75 @@ const setChildrenById = (
|
||||
if (tag.children.length === 0)
|
||||
return tag
|
||||
|
||||
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
|
||||
return { ...tag,
|
||||
children: (setChildrenById (tag.children, targetId, children)
|
||||
.filter (t => t.category !== 'meme' || t.hasChildren)) }
|
||||
}))
|
||||
|
||||
|
||||
const materialPath = (
|
||||
tagName: string,
|
||||
materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
|
||||
|
||||
|
||||
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
deprecatedAt: tag.deprecated ? '' : null,
|
||||
aliases: [],
|
||||
parents: [],
|
||||
postCount: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
hasWiki: false,
|
||||
materialId: null,
|
||||
hasDeerjikists: false,
|
||||
matchedAlias: null })
|
||||
|
||||
|
||||
const updateMaterialFilterQuery = (
|
||||
pathname: string,
|
||||
locationSearch: string,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
materialFilter: MaterialFilter) => {
|
||||
const qs = new URLSearchParams (locationSearch)
|
||||
qs.set ('material_filter', materialFilter)
|
||||
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
|
||||
}
|
||||
|
||||
|
||||
const MaterialFilterButtons: FC<{
|
||||
materialFilter: MaterialFilter
|
||||
onChange: (materialFilter: MaterialFilter) => void
|
||||
}> = ({ materialFilter, onChange }) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTERS.map (value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onChange (value)}
|
||||
className={`rounded-full border px-3 py-1 text-sm ${
|
||||
materialFilter === value
|
||||
? 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400 ' +
|
||||
'dark:bg-sky-950 dark:text-sky-100'
|
||||
: 'border-neutral-300 bg-white text-neutral-700 dark:border-stone-700 ' +
|
||||
'dark:bg-stone-900 dark:text-stone-200' }`}>
|
||||
{FILTER_LABELS[value]}
|
||||
</button>))}
|
||||
</div>)
|
||||
|
||||
|
||||
const MaterialTreeNode: FC<{
|
||||
materialFilter: MaterialFilter
|
||||
nestLevel?: number
|
||||
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||||
openTags: Record<number, boolean>
|
||||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||||
tag: MaterialSidebarTag
|
||||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => {
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
if (open && data && tag.children.length === 0)
|
||||
onChildren (tag.id, data)
|
||||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<li>
|
||||
<div className="flex">
|
||||
<div className="flex-none w-4">
|
||||
{tag.hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||||
className="text-neutral-500 dark:text-stone-400">
|
||||
{open ? <>−</> : '+'}
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<TagLink
|
||||
tag={sidebarTagToTag (tag)}
|
||||
nestLevel={nestLevel}
|
||||
title={tag.name}
|
||||
withCount={false}
|
||||
withWiki={false}
|
||||
to={materialPath (tag.name, materialFilter)}/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{open && tag.children.length > 0 && (
|
||||
<ul>
|
||||
{tag.children.map (child => (
|
||||
<MaterialTreeNode
|
||||
key={child.id}
|
||||
tag={child}
|
||||
nestLevel={nestLevel + 1}
|
||||
materialFilter={materialFilter}
|
||||
openTags={openTags}
|
||||
setOpenTags={setOpenTags}
|
||||
onChildren={onChildren}/>))}
|
||||
</ul>)}
|
||||
</Fragment>)
|
||||
}
|
||||
|
||||
|
||||
const MobileMaterialTreeNode: FC<{
|
||||
depth?: number
|
||||
materialFilter: MaterialFilter
|
||||
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||||
openTags: Record<number, boolean>
|
||||
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||||
tag: MaterialSidebarTag
|
||||
}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => {
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
if (open && data && tag.children.length === 0)
|
||||
onChildren (tag.id, data)
|
||||
}, [data, onChildren, open, tag.children.length, tag.id])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row-reverse items-start gap-2">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm
|
||||
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
|
||||
dark:text-stone-100"
|
||||
style={{ writingMode: 'vertical-rl' }}>
|
||||
<TagLink
|
||||
tag={sidebarTagToTag (tag)}
|
||||
title={tag.name}
|
||||
withCount={false}
|
||||
withWiki={false}
|
||||
to={materialPath (tag.name, materialFilter)}/>
|
||||
</div>
|
||||
{tag.hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||||
className="rounded-full border border-stone-300 bg-white px-2 py-0.5
|
||||
text-sm text-stone-700 dark:border-stone-700
|
||||
dark:bg-stone-900 dark:text-stone-100">
|
||||
{open ? <>−</> : '+'}
|
||||
</button>)}
|
||||
</div>
|
||||
{open && tag.children.length > 0 && (
|
||||
<div
|
||||
className="relative flex flex-row-reverse items-start gap-2 rounded-2xl border
|
||||
border-stone-200 bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
|
||||
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100"
|
||||
style={{ marginTop: `${ depth === 0 ? 1.25 : .75 }rem` }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
|
||||
{tag.children.map (child => (
|
||||
<div key={child.id} className="relative">
|
||||
<MobileMaterialTreeNode
|
||||
tag={child}
|
||||
depth={depth + 1}
|
||||
materialFilter={materialFilter}
|
||||
openTags={openTags}
|
||||
setOpenTags={setOpenTags}
|
||||
onChildren={onChildren}/>
|
||||
</div>))}
|
||||
</div>)}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const MaterialSidebar: FC = () => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const qs = new URLSearchParams (location.search)
|
||||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present')
|
||||
|
||||
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
|
||||
const [tags, setTags] = useState<TagWithDepth[]> ([])
|
||||
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
||||
const mobileRailRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
||||
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
|
||||
|
||||
useEffect (() => {
|
||||
setDesktopTags (rootTags)
|
||||
}, [rootTags])
|
||||
void (async () => {
|
||||
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
|
||||
.filter (t => t.category !== 'meme' || t.hasChildren))
|
||||
}) ()
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const el = mobileRailRef.current
|
||||
if (!(el))
|
||||
return
|
||||
|
||||
requestAnimationFrame (() => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
}, [rootTags, materialFilter])
|
||||
|
||||
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
|
||||
|
||||
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
|
||||
setDesktopTags (prev => {
|
||||
const base = prev.length > 0 ? prev : rootTags
|
||||
return setChildrenById (base, tagId, children)
|
||||
})
|
||||
}
|
||||
|
||||
const handleFilterChange = (value: MaterialFilter) => {
|
||||
setDesktopTags ([])
|
||||
setOpenTags ({ })
|
||||
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
|
||||
}
|
||||
|
||||
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
|
||||
tags.map (tag => (
|
||||
<MaterialTreeNode
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
materialFilter={materialFilter}
|
||||
openTags={openTags}
|
||||
setOpenTags={setOpenTags}
|
||||
onChildren={setChildren}/>)))
|
||||
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
|
||||
ts.map (t => (
|
||||
<Fragment key={t.id}>
|
||||
<li>
|
||||
<div className="flex">
|
||||
<div className="flex-none w-4">
|
||||
{t.hasChildren && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={async e => {
|
||||
e.preventDefault ()
|
||||
if (!(tagFetchedFlags[t.id]))
|
||||
{
|
||||
try
|
||||
{
|
||||
const data =
|
||||
await apiGet<TagWithDepth[]> (
|
||||
'/tags/with-depth', { params: { parent: String (t.id) } })
|
||||
setTags (prev => setChildrenById (prev, t.id, data))
|
||||
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}
|
||||
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
|
||||
}}>
|
||||
{openTags[t.id] ? <>−</> : '+'}
|
||||
</a>)}
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<TagLink
|
||||
tag={t}
|
||||
nestLevel={nestLevel}
|
||||
title={t.name}
|
||||
withCount={false}
|
||||
withWiki={false}
|
||||
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
|
||||
</Fragment>)))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
|
||||
dark:text-stone-100 md:hidden">
|
||||
<MaterialFilterButtons
|
||||
materialFilter={materialFilter}
|
||||
onChange={handleFilterChange}/>
|
||||
<div ref={mobileRailRef} className="mt-3 overflow-x-auto">
|
||||
<div className="flex min-w-max flex-row-reverse gap-3 pb-1">
|
||||
{visibleRootTags.map (tag => (
|
||||
<MobileMaterialTreeNode
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
materialFilter={materialFilter}
|
||||
openTags={openTags}
|
||||
setOpenTags={setOpenTags}
|
||||
onChildren={setChildren}/>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<SidebarComponent>
|
||||
<div className="space-y-4">
|
||||
<MaterialFilterButtons
|
||||
materialFilter={materialFilter}
|
||||
onChange={handleFilterChange}/>
|
||||
{isLoading && (
|
||||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||||
{isError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
タグ一覧の取得に失敗しました.
|
||||
</p>)}
|
||||
{(!isLoading && !isError) && (
|
||||
<ul>
|
||||
{renderDesktopTree (visibleRootTags)}
|
||||
</ul>)}
|
||||
</div>
|
||||
</SidebarComponent>
|
||||
</div>
|
||||
</>)
|
||||
<SidebarComponent>
|
||||
<ul>
|
||||
{renderTags (tags)}
|
||||
</ul>
|
||||
</SidebarComponent>)
|
||||
}
|
||||
|
||||
|
||||
export default MaterialSidebar
|
||||
export default MaterialSidebar
|
||||
@@ -1,83 +0,0 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRef } from 'react'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
|
||||
import type { NiconicoViewerHandle } from '@/types'
|
||||
|
||||
|
||||
describe ('NicoViewer', () => {
|
||||
afterEach (() => {
|
||||
vi.useRealTimers ()
|
||||
})
|
||||
|
||||
it ('does not time out after metadata reports a playable duration', () => {
|
||||
vi.useFakeTimers ()
|
||||
|
||||
const onError = vi.fn ()
|
||||
const onMetadataChange = vi.fn ()
|
||||
const { container } = render (
|
||||
<NicoViewer
|
||||
id="sm12345"
|
||||
width={640}
|
||||
height={360}
|
||||
onMetadataChange={onMetadataChange}
|
||||
onError={onError}/>,
|
||||
)
|
||||
const iframe = container.querySelector ('iframe')
|
||||
expect (iframe).not.toBeNull ()
|
||||
|
||||
fireEvent.load (iframe!)
|
||||
act (() => {
|
||||
window.dispatchEvent (new MessageEvent ('message', {
|
||||
origin: 'https://embed.nicovideo.jp',
|
||||
source: iframe!.contentWindow,
|
||||
data: {
|
||||
eventName: 'playerMetadataChange',
|
||||
data: {
|
||||
currentTime: 7,
|
||||
duration: 120,
|
||||
isVideoMetaDataLoaded: true,
|
||||
maximumBuffered: 30,
|
||||
muted: false,
|
||||
showComment: true,
|
||||
volume: 1,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
act (() => {
|
||||
vi.advanceTimersByTime (8_000)
|
||||
})
|
||||
|
||||
expect (onMetadataChange).toHaveBeenCalled ()
|
||||
expect (onError).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('seeks with milliseconds', () => {
|
||||
const ref = createRef<NiconicoViewerHandle> ()
|
||||
const { container } = render (
|
||||
<NicoViewer
|
||||
ref={ref}
|
||||
id="sm12345"
|
||||
width={640}
|
||||
height={360}/>,
|
||||
)
|
||||
const iframe = container.querySelector ('iframe')!
|
||||
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
|
||||
|
||||
act (() => {
|
||||
ref.current!.seek (7_000)
|
||||
})
|
||||
|
||||
expect (postMessage).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({
|
||||
eventName: 'seek',
|
||||
data: { time: 7_000 },
|
||||
}),
|
||||
'https://embed.nicovideo.jp',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -107,7 +107,8 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
loadCompleteTimerRef.current = setTimeout (() => {
|
||||
onError?.({
|
||||
eventName: 'loadCompleteTimeout',
|
||||
reason: 'niconico video length was not reported by embed'})
|
||||
reason: 'niconico video length was not reported by embed',
|
||||
})
|
||||
}, LOAD_COMPLETE_TIMEOUT_MS)
|
||||
}, [clearLoadCompleteTimer, onError])
|
||||
|
||||
@@ -128,9 +129,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const seek = useCallback ((time: number) => {
|
||||
postToPlayer (
|
||||
{ eventName: 'seek', sourceConnectorType: 1, playerId,
|
||||
data: { time } })
|
||||
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const mute = useCallback (() => {
|
||||
@@ -203,9 +202,6 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
if (data.eventName === 'playerMetadataChange')
|
||||
{
|
||||
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
|
||||
clearLoadCompleteTimer ()
|
||||
|
||||
onMetadataChange?.(data.data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,19 +8,12 @@ const dialogue = vi.hoisted (() => ({
|
||||
confirm: vi.fn (),
|
||||
}))
|
||||
|
||||
const nicoViewer = vi.hoisted (() => ({
|
||||
props: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/NicoViewer', () => ({
|
||||
default: (props: { id: string }) => {
|
||||
nicoViewer.props (props)
|
||||
return <div>Nico:{props.id}</div>
|
||||
},
|
||||
default: ({ id }: { id: string }) => <div>Nico:{id}</div>,
|
||||
}))
|
||||
|
||||
vi.mock ('react-youtube', () => ({
|
||||
@@ -38,64 +31,6 @@ describe ('PostEmbed', () => {
|
||||
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('reports niconico metadata as milliseconds', () => {
|
||||
const onVideoReady = vi.fn ()
|
||||
const onPlaybackChange = vi.fn ()
|
||||
render (
|
||||
<PostEmbed
|
||||
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
|
||||
onVideoReady={onVideoReady}
|
||||
onPlaybackChange={onPlaybackChange}/>,
|
||||
)
|
||||
|
||||
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
|
||||
currentTime: 7_000,
|
||||
duration: 120_000,
|
||||
isVideoMetaDataLoaded: true,
|
||||
maximumBuffered: 30,
|
||||
muted: false,
|
||||
showComment: true,
|
||||
volume: 1,
|
||||
})
|
||||
|
||||
expect (onVideoReady).toHaveBeenCalledWith (120_000)
|
||||
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
|
||||
})
|
||||
|
||||
it ('reports niconico video readiness only once', () => {
|
||||
const onVideoReady = vi.fn ()
|
||||
render (
|
||||
<PostEmbed
|
||||
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
|
||||
onVideoReady={onVideoReady}/>,
|
||||
)
|
||||
|
||||
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
|
||||
title: '動画',
|
||||
videoId: 'sm12345',
|
||||
lengthInSeconds: 120,
|
||||
thumbnailUrl: 'https://example.com/thumb.jpg',
|
||||
description: '',
|
||||
viewCount: 1,
|
||||
commentCount: 2,
|
||||
mylistCount: 3,
|
||||
postedAt: '2026-01-02T03:04:05.000Z',
|
||||
watchId: 12345,
|
||||
})
|
||||
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
|
||||
currentTime: 7_000,
|
||||
duration: 120_000,
|
||||
isVideoMetaDataLoaded: true,
|
||||
maximumBuffered: 30,
|
||||
muted: false,
|
||||
showComment: true,
|
||||
volume: 1,
|
||||
})
|
||||
|
||||
expect (onVideoReady).toHaveBeenCalledTimes (1)
|
||||
expect (onVideoReady).toHaveBeenCalledWith (120_000)
|
||||
})
|
||||
|
||||
it ('embeds x/twitter status URLs', () => {
|
||||
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
@@ -36,17 +36,6 @@ const PostEmbed: FC<Props> = ({
|
||||
const dialogue = useDialogue ()
|
||||
const [framed, setFramed] = useState (false)
|
||||
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
||||
const niconicoVideoReadyRef = useRef (false)
|
||||
|
||||
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
|
||||
if (niconicoVideoReadyRef.current
|
||||
|| !(Number.isFinite (durationMs))
|
||||
|| durationMs <= 0)
|
||||
return
|
||||
|
||||
niconicoVideoReadyRef.current = true
|
||||
onVideoReady?.(durationMs)
|
||||
}, [onVideoReady])
|
||||
|
||||
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
|
||||
const currentTime = await player.getCurrentTime ()
|
||||
@@ -91,20 +80,15 @@ const PostEmbed: FC<Props> = ({
|
||||
}
|
||||
|
||||
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
|
||||
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
|
||||
onVideoReady?.(info.lengthInSeconds * 1_000)
|
||||
onLoadComplete?.(info)
|
||||
}
|
||||
|
||||
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
|
||||
notifyNiconicoVideoReady (meta.duration)
|
||||
onPlaybackChange?.(meta.currentTime)
|
||||
onMetadataChange?.(meta)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
niconicoVideoReadyRef.current = false
|
||||
}, [post.url])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(youtubePlayer) || !(onPlaybackChange))
|
||||
return
|
||||
|
||||
@@ -19,7 +19,8 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => (
|
||||
errors }: Props,
|
||||
) => (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
|
||||
@@ -37,7 +37,8 @@ const renderTagTree = (
|
||||
path: string,
|
||||
suppressClickRef: MutableRefObject<boolean>,
|
||||
parentTagId?: number,
|
||||
sp?: boolean): ReactNode[] => {
|
||||
sp?: boolean,
|
||||
): ReactNode[] => {
|
||||
const key = `${ path }-${ tag.id }`
|
||||
|
||||
const self = (
|
||||
@@ -63,7 +64,8 @@ const renderTagTree = (
|
||||
|
||||
const isDescendant = (
|
||||
root: Tag,
|
||||
targetId: number): boolean => {
|
||||
targetId: number,
|
||||
): boolean => {
|
||||
if (!(root.children))
|
||||
return false
|
||||
|
||||
@@ -81,7 +83,8 @@ const isDescendant = (
|
||||
|
||||
const findTag = (
|
||||
byCat: TagByCategory,
|
||||
id: number): Tag | undefined => {
|
||||
id: number,
|
||||
): Tag | undefined => {
|
||||
const walk = (nodes: Tag[]): Tag | undefined => {
|
||||
for (const t of nodes)
|
||||
{
|
||||
@@ -127,7 +130,8 @@ const buildTagByCategory = (post: Post): TagByCategory => {
|
||||
|
||||
const changeCategory = async (
|
||||
tagId: number,
|
||||
category: Category): Promise<void> => {
|
||||
category: Category,
|
||||
): Promise<void> => {
|
||||
await apiPatch (`/tags/${ tagId }`, { category })
|
||||
}
|
||||
|
||||
@@ -290,7 +294,8 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
||||
addEventListener ('click', e => {
|
||||
e.preventDefault ()
|
||||
e.stopPropagation ()
|
||||
suppressClickRef.current = false}, { capture: true, once: true })
|
||||
suppressClickRef.current = false
|
||||
}, { capture: true, once: true })
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
setActiveTagId (null)
|
||||
|
||||
@@ -18,21 +18,6 @@ describe ('TagLink', () => {
|
||||
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', () => {
|
||||
renderWithProviders (
|
||||
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
||||
|
||||
@@ -128,4 +128,4 @@ const TagLink: FC<Props> = ({ tag,
|
||||
</>)
|
||||
}
|
||||
|
||||
export default TagLink
|
||||
export default TagLink
|
||||
@@ -66,8 +66,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||
{ name: '上映会 (β)', to: '/theatres/1' }] },
|
||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
|
||||
@@ -16,7 +16,8 @@ const range = (start: number, end: number): number[] =>
|
||||
const getPages = (
|
||||
page: number,
|
||||
total: number,
|
||||
siblingCount: number): (number | '…')[] => {
|
||||
siblingCount: number,
|
||||
): (number | '…')[] => {
|
||||
if (total <= 1)
|
||||
return [1]
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
choice: options => new Promise (resolve => {
|
||||
push ({ kind: 'choice',
|
||||
options: options as ChoiceOptions<string>,
|
||||
resolve: resolve as (value: string | null) => void })}) }), [push])
|
||||
resolve: resolve as (value: string | null) => void })
|
||||
}) }), [push])
|
||||
|
||||
const active = queue[0]
|
||||
|
||||
|
||||
@@ -10,35 +10,41 @@ const buttonVariants = cva (
|
||||
'rounded-md text-sm font-medium transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'].join (' '),
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
].join (' '),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
|
||||
default:
|
||||
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
|
||||
|
||||
destructive:
|
||||
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
|
||||
destructive:
|
||||
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
|
||||
|
||||
outline:
|
||||
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
|
||||
outline:
|
||||
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
|
||||
|
||||
secondary:
|
||||
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
|
||||
secondary:
|
||||
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
|
||||
|
||||
ghost:
|
||||
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
|
||||
ghost:
|
||||
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
|
||||
|
||||
link:
|
||||
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
|
||||
link:
|
||||
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10'}},
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'}})
|
||||
size: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -51,11 +57,13 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>)
|
||||
})
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -22,9 +22,11 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className)}
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>))
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
@@ -60,7 +62,8 @@ const DialogContent = React.forwardRef<
|
||||
<span className="sr-only">閉ぢる</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>))
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
@@ -70,9 +73,11 @@ const DialogHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className)}
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>)
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
@@ -82,9 +87,11 @@ const DialogFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className)}
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>)
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
@@ -95,9 +102,11 @@ const DialogTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className)}
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>))
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
@@ -108,7 +117,8 @@ const DialogDescription = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>))
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
)
|
||||
}
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
+129
-115
@@ -1,115 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
)}
|
||||
{...props}
|
||||
/>
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" } }
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
)
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
)}
|
||||
{...props}
|
||||
/>
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport } from '@/components/ui/toast'
|
||||
|
||||
|
||||
export const Toaster = () => {
|
||||
const { toasts } = useToast ()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map (({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id}
|
||||
className="bg-gray-300/80 dark:bg-gray-700/80"
|
||||
{...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport } from '@/components/ui/toast'
|
||||
|
||||
|
||||
export const Toaster = () => {
|
||||
const { toasts } = useToast ()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map (({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id}
|
||||
className="bg-gray-300/80 dark:bg-gray-700/80"
|
||||
{...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId})
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
@@ -68,14 +69,17 @@ export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)}
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t)}
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
@@ -83,31 +87,36 @@ export const reducer = (state: State, action: Action): State => {
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false}
|
||||
: t)}
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: []}
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId)}
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +139,8 @@ function toast({ ...props }: Toast) {
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id }})
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
@@ -140,13 +150,16 @@ function toast({ ...props }: Toast) {
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
}}})
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update}
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
@@ -157,7 +170,7 @@ function useToast() {
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
@@ -165,7 +178,8 @@ function useToast() {
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })}
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
|
||||
@@ -3,7 +3,8 @@ const ENV: string = 'development'
|
||||
|
||||
const config = {
|
||||
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002',
|
||||
SITE_TITLE: 'ぼざクリ タグ広場'}
|
||||
SITE_TITLE: 'ぼざクリ タグ広場'
|
||||
}
|
||||
|
||||
export const API_BASE_URL = config.API_BASE_URL
|
||||
export const SITE_TITLE = config.SITE_TITLE
|
||||
|
||||
+10
-5
@@ -10,7 +10,8 @@ export const CATEGORIES = [
|
||||
'general',
|
||||
'material',
|
||||
'meta',
|
||||
'nico'] as const
|
||||
'nico',
|
||||
] as const
|
||||
|
||||
export const CATEGORY_NAMES: Record<Category, string> = {
|
||||
deerjikist: 'ニジラー',
|
||||
@@ -19,14 +20,16 @@ export const CATEGORY_NAMES: Record<Category, string> = {
|
||||
general: '一般',
|
||||
material: '素材',
|
||||
meta: 'メタタグ',
|
||||
nico: 'ニコニコタグ'} as const
|
||||
nico: 'ニコニコタグ',
|
||||
} as const
|
||||
|
||||
export const FETCH_POSTS_ORDER_FIELDS = [
|
||||
'title',
|
||||
'url',
|
||||
'original_created_at',
|
||||
'created_at',
|
||||
'updated_at'] as const
|
||||
'updated_at',
|
||||
] as const
|
||||
|
||||
export const PLATFORMS = ['nico', 'youtube'] as const
|
||||
|
||||
@@ -40,11 +43,13 @@ export const TAG_COLOUR = {
|
||||
general: 'cyan',
|
||||
material: 'orange',
|
||||
meta: 'yellow',
|
||||
nico: 'gray'} as const satisfies Record<Category, string>
|
||||
nico: 'gray',
|
||||
} as const satisfies Record<Category, string>
|
||||
|
||||
export const USER_ROLES = ['admin', 'member', 'guest'] as const
|
||||
|
||||
export const ViewFlagBehavior = {
|
||||
OnShowedDetail: 1,
|
||||
OnClickedLink: 2,
|
||||
NotAuto: 3} as const
|
||||
NotAuto: 3,
|
||||
} as const
|
||||
|
||||
+12
-6
@@ -23,7 +23,8 @@ const apiP = async <T> (
|
||||
method: 'post' | 'put' | 'patch',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt): Promise<T> => {
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
const res = await client[method] (path, body ?? { }, withUserCode (opt))
|
||||
if (opt?.responseType === 'blob')
|
||||
return res.data as T
|
||||
@@ -33,7 +34,8 @@ const apiP = async <T> (
|
||||
|
||||
export const apiGet = async <T> (
|
||||
path: string,
|
||||
opt?: Opt): Promise<T> => {
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
const res = await client.get (path, withUserCode (opt))
|
||||
if (opt?.responseType === 'blob')
|
||||
return res.data as T
|
||||
@@ -44,24 +46,28 @@ export const apiGet = async <T> (
|
||||
export const apiPost = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('post', path, body, opt)
|
||||
|
||||
|
||||
export const apiPut = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('put', path, body, opt)
|
||||
|
||||
|
||||
export const apiPatch = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('patch', path, body, opt)
|
||||
|
||||
|
||||
export const apiDelete = async <T = void> (
|
||||
path: string,
|
||||
opt?: Opt): Promise<T> => {
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
const res = await client.delete (path, withUserCode (opt))
|
||||
if (res.data == null || res.data === '')
|
||||
return undefined as T
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
import {
|
||||
buildGekanatorQuestions,
|
||||
expectedAnswerForQuestion,
|
||||
fetchGekanatorPosts,
|
||||
fetchGekanatorQuestions,
|
||||
learnedSemanticSideForPost,
|
||||
questionIdForCondition,
|
||||
restoreGekanatorQuestion,
|
||||
saveGekanatorExtraQuestionAnswers,
|
||||
saveGekanatorGame,
|
||||
saveGekanatorQuestionSuggestion,
|
||||
} from '@/lib/gekanator'
|
||||
|
||||
import type {
|
||||
GekanatorAnswerLog,
|
||||
StoredGekanatorQuestion,
|
||||
} from '@/lib/gekanator'
|
||||
import type { Post } from '@/types'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockedApiPost = vi.mocked(apiPost)
|
||||
const mockedApiGet = vi.mocked(apiGet)
|
||||
|
||||
const post = (overrides: Partial<Post> = {}): Post => ({
|
||||
id: 1,
|
||||
versionNo: 1,
|
||||
url: 'https://example.com/posts/1',
|
||||
title: 'post title',
|
||||
thumbnail: null,
|
||||
thumbnailBase: null,
|
||||
tags: [],
|
||||
viewed: false,
|
||||
related: [],
|
||||
originalCreatedFrom: null,
|
||||
originalCreatedBefore: null,
|
||||
createdAt: '2026-06-10T00:00:00.000Z',
|
||||
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||
uploadedUser: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Gekanator API functions', () => {
|
||||
it('returns posts from the Gekanator posts endpoint', async () => {
|
||||
const posts = [post()]
|
||||
mockedApiGet.mockResolvedValueOnce({ posts })
|
||||
|
||||
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
|
||||
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
|
||||
})
|
||||
|
||||
it('returns questions from the Gekanator questions endpoint', async () => {
|
||||
const questions: StoredGekanatorQuestion[] = []
|
||||
mockedApiGet.mockResolvedValueOnce({ questions })
|
||||
|
||||
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
|
||||
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expectedAnswerForQuestion', () => {
|
||||
it('returns a direct example answer when present', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 999,
|
||||
answer: 'yes',
|
||||
threshold: 0.65,
|
||||
},
|
||||
exampleAnswers: {
|
||||
1: 'partial',
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial')
|
||||
})
|
||||
|
||||
it('returns the condition answer for the original post_similarity post', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 123,
|
||||
answer: 'probably_no',
|
||||
threshold: 0.65,
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no')
|
||||
})
|
||||
|
||||
it('returns null for an unrelated post_similarity post without examples', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 123,
|
||||
answer: 'yes',
|
||||
threshold: 0.65,
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns yes for a matching tag question', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'tag:character:喜多郁代',
|
||||
text: '喜多ちゃんが関係してる?',
|
||||
kind: 'tag',
|
||||
condition: {
|
||||
type: 'tag',
|
||||
key: 'character:喜多郁代',
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
expectedAnswerForQuestion(
|
||||
question,
|
||||
post({
|
||||
tags: [
|
||||
{
|
||||
id: 1,
|
||||
name: '喜多郁代',
|
||||
category: 'character',
|
||||
aliases: [],
|
||||
parents: [],
|
||||
postCount: 1,
|
||||
createdAt: '2026-06-10T00:00:00.000Z',
|
||||
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||
deprecatedAt: null,
|
||||
hasWiki: false,
|
||||
hasDeerjikists: false,
|
||||
materialId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('yes')
|
||||
})
|
||||
|
||||
it('returns no for a non-matching tag question', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'tag:character:喜多郁代',
|
||||
text: '喜多ちゃんが関係してる?',
|
||||
kind: 'tag',
|
||||
condition: {
|
||||
type: 'tag',
|
||||
key: 'character:喜多郁代',
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
|
||||
})
|
||||
|
||||
it('ignores example answers for direct title facts', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'title:length-at-least:20',
|
||||
text: 'タイトルは 20 文字以上?',
|
||||
kind: 'title',
|
||||
condition: {
|
||||
type: 'title-length-at-least',
|
||||
length: 20,
|
||||
},
|
||||
exampleAnswers: {
|
||||
1: 'yes',
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
|
||||
})
|
||||
|
||||
it('returns yes for matching title-contains questions', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'title:contains:結束バンド',
|
||||
text: '題名に「結束バンド」が含まれる?',
|
||||
kind: 'title',
|
||||
condition: {
|
||||
type: 'title-contains',
|
||||
text: '結束バンド',
|
||||
},
|
||||
}
|
||||
|
||||
expect(expectedAnswerForQuestion(
|
||||
question,
|
||||
post({ title: '結束バンドのライブ' }),
|
||||
)).toBe('yes')
|
||||
expect(expectedAnswerForQuestion(
|
||||
question,
|
||||
post({ title: '後藤ひとりの休日' }),
|
||||
)).toBe('no')
|
||||
})
|
||||
})
|
||||
|
||||
describe('learnedSemanticSideForPost', () => {
|
||||
it('classifies post_similarity examples as positive, negative, or unknown', () => {
|
||||
const question: StoredGekanatorQuestion = {
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 123,
|
||||
answer: 'partial',
|
||||
threshold: 0.65,
|
||||
},
|
||||
exampleAnswers: {
|
||||
1: 'yes',
|
||||
2: 'probably_no',
|
||||
},
|
||||
}
|
||||
|
||||
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
|
||||
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
|
||||
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
|
||||
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreGekanatorQuestion', () => {
|
||||
it('uses default source and priority weight when omitted', () => {
|
||||
const question = restoreGekanatorQuestion({
|
||||
id: 'tag:character:喜多郁代',
|
||||
text: '喜多ちゃんが関係してる?',
|
||||
kind: 'tag',
|
||||
condition: {
|
||||
type: 'tag',
|
||||
key: 'character:喜多郁代',
|
||||
},
|
||||
})
|
||||
|
||||
expect(question.source).toBe('default')
|
||||
expect(question.priorityWeight).toBe(1)
|
||||
})
|
||||
|
||||
it('tests a post_similarity question using direct examples', () => {
|
||||
const question = restoreGekanatorQuestion({
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 999,
|
||||
answer: 'yes',
|
||||
threshold: 0.65,
|
||||
},
|
||||
exampleAnswers: {
|
||||
1: 'yes',
|
||||
2: 'no',
|
||||
},
|
||||
})
|
||||
|
||||
expect(question.test(post({ id: 1 }))).toBe(true)
|
||||
expect(question.test(post({ id: 2 }))).toBe(false)
|
||||
expect(question.test(post({ id: 3 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('tests a post_similarity question against its configured partial answer', () => {
|
||||
const question = restoreGekanatorQuestion({
|
||||
id: 'post-similarity:10',
|
||||
text: '喜多ちゃんが泣いてる?',
|
||||
kind: 'post_similarity',
|
||||
source: 'user_suggested',
|
||||
priorityWeight: 1.2,
|
||||
condition: {
|
||||
type: 'post-similarity',
|
||||
postId: 999,
|
||||
answer: 'partial',
|
||||
threshold: 0.65,
|
||||
},
|
||||
exampleAnswers: {
|
||||
1: 'partial',
|
||||
2: 'yes',
|
||||
},
|
||||
})
|
||||
|
||||
expect(question.test(post({ id: 1 }))).toBe(true)
|
||||
expect(question.test(post({ id: 2 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes legacy title-length-greater-than questions', () => {
|
||||
const question = restoreGekanatorQuestion({
|
||||
id: 'title:length-greater-than:20',
|
||||
text: '題名が長めの投稿?',
|
||||
kind: 'title',
|
||||
condition: {
|
||||
type: 'title-length-greater-than',
|
||||
length: 20,
|
||||
},
|
||||
})
|
||||
|
||||
expect(question.id).toBe('title:length-at-least:21')
|
||||
expect(question.condition).toEqual({
|
||||
type: 'title-length-at-least',
|
||||
length: 21,
|
||||
})
|
||||
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
|
||||
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
|
||||
})
|
||||
|
||||
it('restores title-contains questions with a title matcher', () => {
|
||||
const question = restoreGekanatorQuestion({
|
||||
id: 'title:contains:結束バンド',
|
||||
text: '題名に「結束バンド」が含まれる?',
|
||||
kind: 'title',
|
||||
condition: {
|
||||
type: 'title-contains',
|
||||
text: '結束バンド',
|
||||
},
|
||||
})
|
||||
|
||||
expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true)
|
||||
expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGekanatorQuestions', () => {
|
||||
it('builds quantitative title length questions', () => {
|
||||
const questions = buildGekanatorQuestions([
|
||||
post({ id: 1, title: 'a' }),
|
||||
post({ id: 2, title: 'bb' }),
|
||||
post({ id: 3, title: 'ccc' }),
|
||||
post({ id: 4, title: 'dddd' }),
|
||||
])
|
||||
const titleQuestion = questions.find(question =>
|
||||
question.condition.type === 'title-length-at-least')
|
||||
|
||||
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
|
||||
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
|
||||
})
|
||||
|
||||
it('builds title-contains questions from repeated title words', () => {
|
||||
const questions = buildGekanatorQuestions([
|
||||
post({ id: 1, title: '結束バンド ライブ' }),
|
||||
post({ id: 2, title: '結束バンド 新曲' }),
|
||||
post({ id: 3, title: '後藤ひとり 練習' }),
|
||||
post({ id: 4, title: '伊地知虹夏 練習' }),
|
||||
])
|
||||
|
||||
const titleContainsQuestion = questions.find(question =>
|
||||
question.condition.type === 'title-contains'
|
||||
&& question.condition.text === '結束バンド')
|
||||
|
||||
expect(titleContainsQuestion).toMatchObject({
|
||||
id: 'title:contains:結束バンド',
|
||||
text: '題名に「結束バンド」が含まれる?',
|
||||
kind: 'title',
|
||||
source: 'default',
|
||||
priorityWeight: .96,
|
||||
})
|
||||
expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true)
|
||||
expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('honors question caps and title-contains toggles', () => {
|
||||
const posts = [
|
||||
post({ id: 1, title: '結束バンド ライブ' }),
|
||||
post({ id: 2, title: '結束バンド 新曲' }),
|
||||
post({ id: 3, title: '後藤ひとり 練習' }),
|
||||
post({ id: 4, title: '伊地知虹夏 練習' }),
|
||||
]
|
||||
|
||||
const capped = buildGekanatorQuestions(posts, {
|
||||
titleContainsCap: 1,
|
||||
totalQuestionCap: 1,
|
||||
})
|
||||
const withoutTitleContains = buildGekanatorQuestions(posts, {
|
||||
includeTitleContains: false,
|
||||
})
|
||||
|
||||
expect(capped).toHaveLength(1)
|
||||
expect(withoutTitleContains.some(question =>
|
||||
question.condition.type === 'title-contains')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('questionIdForCondition', () => {
|
||||
it('builds stable ids for title-contains questions', () => {
|
||||
expect(questionIdForCondition({
|
||||
type: 'title-contains',
|
||||
text: '結束バンド',
|
||||
})).toBe('title:contains:結束バンド')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gekanator API writers', () => {
|
||||
beforeEach(() => {
|
||||
mockedApiPost.mockReset()
|
||||
})
|
||||
|
||||
it('sends game results using snake_case request keys', async () => {
|
||||
mockedApiPost.mockResolvedValue({ id: 100 })
|
||||
|
||||
const answers: GekanatorAnswerLog[] = [
|
||||
{
|
||||
questionId: 'tag:character:喜多郁代',
|
||||
questionText: '喜多ちゃんが関係してる?',
|
||||
questionCondition: {
|
||||
type: 'tag',
|
||||
key: 'character:喜多郁代',
|
||||
},
|
||||
questionMode: 'normal',
|
||||
questionPurpose: 'effective_user_suggested',
|
||||
effectiveQuestion: true,
|
||||
learningQuestion: false,
|
||||
answer: 'yes',
|
||||
originalAnswer: 'partial',
|
||||
},
|
||||
]
|
||||
|
||||
await expect(
|
||||
saveGekanatorGame({
|
||||
guessedPostId: 1,
|
||||
correctPostId: 2,
|
||||
answers,
|
||||
}),
|
||||
).resolves.toEqual({ id: 100 })
|
||||
|
||||
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', {
|
||||
guessed_post_id: 1,
|
||||
correct_post_id: 2,
|
||||
answers: [
|
||||
{
|
||||
question_id: 'tag:character:喜多郁代',
|
||||
question_text: '喜多ちゃんが関係してる?',
|
||||
question_condition: {
|
||||
type: 'tag',
|
||||
key: 'character:喜多郁代',
|
||||
},
|
||||
question_mode: 'normal',
|
||||
question_purpose: 'effective_user_suggested',
|
||||
effective_question: true,
|
||||
learning_question: false,
|
||||
answer: 'yes',
|
||||
original_answer: 'partial',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends question suggestions using snake_case request keys', async () => {
|
||||
mockedApiPost.mockResolvedValue({
|
||||
id: 10,
|
||||
count: 1,
|
||||
})
|
||||
|
||||
await expect(
|
||||
saveGekanatorQuestionSuggestion({
|
||||
gekanatorGameId: 100,
|
||||
questionText: '喜多ちゃんが泣いてる?',
|
||||
answer: 'yes',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
id: 10,
|
||||
count: 1,
|
||||
})
|
||||
|
||||
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', {
|
||||
gekanator_game_id: 100,
|
||||
question_text: '喜多ちゃんが泣いてる?',
|
||||
answer: 'yes',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends extra question answers using snake_case request keys', async () => {
|
||||
mockedApiPost.mockResolvedValue({
|
||||
count: 2,
|
||||
})
|
||||
|
||||
await saveGekanatorExtraQuestionAnswers({
|
||||
gameId: 100,
|
||||
answers: [
|
||||
{
|
||||
questionId: 10,
|
||||
answer: 'yes',
|
||||
},
|
||||
{
|
||||
questionId: 11,
|
||||
answer: 'probably_no',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockedApiPost).toHaveBeenCalledWith(
|
||||
'/gekanator/games/100/extra_question_answers',
|
||||
{
|
||||
answers: [
|
||||
{
|
||||
question_id: 10,
|
||||
answer: 'yes',
|
||||
},
|
||||
{
|
||||
question_id: 11,
|
||||
answer: 'probably_no',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,661 +0,0 @@
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
|
||||
import type { Post } from '@/types'
|
||||
|
||||
export type GekanatorAnswerValue =
|
||||
| 'yes'
|
||||
| 'no'
|
||||
| 'partial'
|
||||
| 'probably_no'
|
||||
| 'unknown'
|
||||
|
||||
export type LearnedSemanticSide =
|
||||
| 'positive'
|
||||
| 'negative'
|
||||
| 'unknown'
|
||||
|
||||
export type GekanatorQuestionPurpose =
|
||||
| 'effective_user_suggested'
|
||||
| 'learning_user_suggested'
|
||||
| 'normal'
|
||||
|
||||
export type GekanatorAnswerLog = {
|
||||
questionId: string
|
||||
questionText: string
|
||||
questionCondition?: GekanatorQuestionCondition
|
||||
questionMode?: 'normal' | 'winning_run'
|
||||
questionPurpose?: GekanatorQuestionPurpose
|
||||
effectiveQuestion?: boolean
|
||||
learningQuestion?: boolean
|
||||
answer: GekanatorAnswerValue
|
||||
originalAnswer: GekanatorAnswerValue }
|
||||
|
||||
export type GekanatorQuestionKind =
|
||||
| 'tag'
|
||||
| 'source'
|
||||
| 'title'
|
||||
| 'original_date'
|
||||
| 'post_similarity'
|
||||
|
||||
export type GekanatorQuestionSource =
|
||||
| 'default'
|
||||
| 'user_suggested'
|
||||
| 'ai_generated'
|
||||
| 'admin_curated'
|
||||
|
||||
export type GekanatorPerformanceMode = 'normal'
|
||||
|
||||
export type GekanatorQuestionCondition =
|
||||
| { type: 'tag'; key: string }
|
||||
| { type: 'source'; host: string }
|
||||
| { type: 'original-year'; year: number }
|
||||
| { type: 'original-month'; month: number }
|
||||
| { type: 'original-month-day'; monthDay: string }
|
||||
| { type: 'title-length-at-least'; length: number }
|
||||
| { type: 'title-length-greater-than'; length: number }
|
||||
| { type: 'title-has-ascii' }
|
||||
| { type: 'title-contains'; text: string }
|
||||
| {
|
||||
type: 'post-similarity'
|
||||
postId: number
|
||||
answer: GekanatorAnswerValue
|
||||
threshold: number
|
||||
}
|
||||
|
||||
|
||||
type NonPostSimilarityCondition = Exclude<
|
||||
GekanatorQuestionCondition,
|
||||
{ type: 'post-similarity' }
|
||||
>
|
||||
|
||||
export type GekanatorExtraQuestion = {
|
||||
id: number
|
||||
text: string
|
||||
source: GekanatorQuestionSource
|
||||
priorityWeight: number }
|
||||
|
||||
export type StoredGekanatorQuestion = {
|
||||
recordId?: number
|
||||
id: string
|
||||
text: string
|
||||
kind: GekanatorQuestionKind
|
||||
condition: GekanatorQuestionCondition
|
||||
source?: GekanatorQuestionSource
|
||||
priorityWeight?: number
|
||||
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
|
||||
|
||||
export type GekanatorQuestion = {
|
||||
recordId?: number
|
||||
id: string
|
||||
text: string
|
||||
kind: GekanatorQuestionKind
|
||||
condition: GekanatorQuestionCondition
|
||||
source: GekanatorQuestionSource
|
||||
priorityWeight: number
|
||||
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
|
||||
test: (post: Post) => boolean }
|
||||
|
||||
export type BuildGekanatorQuestionsOptions = {
|
||||
includeTitleContains?: boolean
|
||||
tagQuestionCap?: number
|
||||
titleContainsCap?: number
|
||||
totalQuestionCap?: number
|
||||
}
|
||||
|
||||
|
||||
export const normalizeTitleLengthCondition = (
|
||||
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'title-length-greater-than':
|
||||
return {
|
||||
type: 'title-length-at-least',
|
||||
length: condition.length + 1 }
|
||||
default:
|
||||
return condition
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const titleLengthMinimumForCondition = (
|
||||
condition: GekanatorQuestionCondition): number | null => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'title-length-at-least':
|
||||
return condition.length
|
||||
case 'title-length-greater-than':
|
||||
return condition.length + 1
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const questionIdForCondition = (
|
||||
condition: NonPostSimilarityCondition): string => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'tag':
|
||||
return `tag:${ condition.key }`
|
||||
case 'source':
|
||||
return `source:${ condition.host }`
|
||||
case 'original-year':
|
||||
return `original-year:${ condition.year }`
|
||||
case 'original-month':
|
||||
return `original-month:${ condition.month }`
|
||||
case 'original-month-day':
|
||||
return `original-month-day:${ condition.monthDay }`
|
||||
case 'title-length-at-least':
|
||||
case 'title-length-greater-than':
|
||||
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
|
||||
case 'title-has-ascii':
|
||||
return 'title:ascii'
|
||||
case 'title-contains':
|
||||
return `title:contains:${ condition.text }`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const directExampleAnswerFor = (
|
||||
question: StoredGekanatorQuestion,
|
||||
post: Post): GekanatorAnswerValue | null => {
|
||||
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
|
||||
return null
|
||||
|
||||
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
||||
if (direct)
|
||||
return direct
|
||||
|
||||
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
|
||||
return question.condition.answer
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export const isLearnedSemanticQuestion = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
|
||||
question.kind === 'post_similarity'
|
||||
&& question.source === 'user_suggested'
|
||||
|
||||
|
||||
export const learnedSemanticSideForAnswer = (
|
||||
answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
|
||||
if (answer === 'yes' || answer === 'partial')
|
||||
return 'positive'
|
||||
|
||||
if (answer === 'no' || answer === 'probably_no')
|
||||
return 'negative'
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||
const counts = new Map<T, number> ()
|
||||
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||
return counts
|
||||
}
|
||||
|
||||
|
||||
const median = (values: number[]): number => {
|
||||
const sorted = [...values].sort ((a, b) => a - b)
|
||||
return sorted[Math.floor (sorted.length / 2)] ?? 0
|
||||
}
|
||||
|
||||
|
||||
const hostOf = (post: Post): string | null => {
|
||||
try
|
||||
{
|
||||
return new URL (post.url).hostname.replace (/^www\./, '')
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const originalYearOf = (post: Post): number | null => {
|
||||
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
||||
if (!(value))
|
||||
return null
|
||||
|
||||
const date = new Date (value)
|
||||
if (Number.isNaN (date.getTime ()))
|
||||
return null
|
||||
|
||||
return date.getFullYear ()
|
||||
}
|
||||
|
||||
|
||||
const originalDateOf = (post: Post): Date | null => {
|
||||
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
||||
if (!(value))
|
||||
return null
|
||||
|
||||
const date = new Date (value)
|
||||
if (Number.isNaN (date.getTime ()))
|
||||
return null
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
const originalMonthOf = (post: Post): number | null => {
|
||||
const date = originalDateOf (post)
|
||||
if (!(date))
|
||||
return null
|
||||
|
||||
return date.getMonth () + 1
|
||||
}
|
||||
|
||||
|
||||
const originalMonthDayOf = (post: Post): string | null => {
|
||||
const date = originalDateOf (post)
|
||||
if (!(date))
|
||||
return null
|
||||
|
||||
return `${ date.getMonth () + 1 }-${ date.getDate () }`
|
||||
}
|
||||
|
||||
|
||||
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
|
||||
`${ category }:${ name }`
|
||||
|
||||
|
||||
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
|
||||
const [category, ...rest] = key.split (':')
|
||||
return { category: category ?? '', name: rest.join (':') }
|
||||
}
|
||||
|
||||
|
||||
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
|
||||
|
||||
|
||||
const tagQuestionText = (category: string, label: string): string => {
|
||||
switch (category)
|
||||
{
|
||||
case 'deerjikist':
|
||||
return `作者・ニジラーとして「${ label }」に関係してゐる?`
|
||||
case 'meme':
|
||||
return `元ネタ・ミームとして「${ label }」に関係しさう?`
|
||||
case 'character':
|
||||
return `「${ label }」といふキャラクターが関係してゐる?`
|
||||
case 'material':
|
||||
return `素材として「${ label }」に関係してゐる?`
|
||||
case 'nico':
|
||||
return `ニコニコに「${ label }」といふタグが付いてゐる?`
|
||||
case 'general':
|
||||
case 'meta':
|
||||
default:
|
||||
return `内容として「${ label }」に関係しさう?`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const questionableTag = (post: Post, key: string): boolean => {
|
||||
const { category, name } = tagFromQuestionKey (key)
|
||||
|
||||
return (
|
||||
post.tags.some (tag =>
|
||||
tag.name === name
|
||||
&& tag.category === category
|
||||
&& !(tag.category === 'meta')
|
||||
&& !(tag.name.includes ('タグ希望'))
|
||||
&& !(tag.name.includes ('bot操作'))))
|
||||
}
|
||||
|
||||
|
||||
const questionMatches = (
|
||||
post: Post,
|
||||
question: StoredGekanatorQuestion): boolean => {
|
||||
const directAnswer = directExampleAnswerFor (question, post)
|
||||
if (directAnswer)
|
||||
return question.kind === 'post_similarity'
|
||||
? learnedSemanticSideForAnswer (directAnswer) === 'positive'
|
||||
: directAnswer === 'yes'
|
||||
|
||||
switch (question.condition.type)
|
||||
{
|
||||
case 'tag':
|
||||
return questionableTag (post, question.condition.key)
|
||||
case 'source':
|
||||
return hostOf (post) === question.condition.host
|
||||
case 'original-year':
|
||||
return originalYearOf (post) === question.condition.year
|
||||
case 'original-month':
|
||||
return originalMonthOf (post) === question.condition.month
|
||||
case 'original-month-day':
|
||||
return originalMonthDayOf (post) === question.condition.monthDay
|
||||
case 'title-length-at-least':
|
||||
return (post.title?.length ?? 0) >= question.condition.length
|
||||
case 'title-length-greater-than':
|
||||
return (post.title?.length ?? 0) > question.condition.length
|
||||
case 'title-has-ascii':
|
||||
return /[A-Za-z0-9]/.test (post.title ?? '')
|
||||
case 'title-contains':
|
||||
return (post.title ?? '').includes (question.condition.text)
|
||||
case 'post-similarity':
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const expectedAnswerForQuestion = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||
post: Post | null): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(post))
|
||||
return null
|
||||
|
||||
const directAnswer = directExampleAnswerFor (question, post)
|
||||
if (directAnswer)
|
||||
return directAnswer
|
||||
|
||||
switch (question.condition.type)
|
||||
{
|
||||
case 'post-similarity':
|
||||
if (question.condition.postId === post.id)
|
||||
return question.condition.answer
|
||||
|
||||
return null
|
||||
case 'tag':
|
||||
case 'source':
|
||||
case 'original-year':
|
||||
case 'original-month':
|
||||
case 'original-month-day':
|
||||
case 'title-length-at-least':
|
||||
case 'title-length-greater-than':
|
||||
case 'title-has-ascii':
|
||||
case 'title-contains':
|
||||
return questionMatches (post, question) ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const learnedSemanticSideForPost = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||
post: Post | null): LearnedSemanticSide =>
|
||||
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
|
||||
|
||||
|
||||
export const restoreGekanatorQuestion = (
|
||||
question: StoredGekanatorQuestion): GekanatorQuestion => {
|
||||
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
|
||||
const normalizedQuestion = {
|
||||
...question,
|
||||
recordId: question.recordId,
|
||||
id: normalizedCondition.type === 'title-length-at-least'
|
||||
? `title:length-at-least:${ normalizedCondition.length }`
|
||||
: question.id,
|
||||
condition: normalizedCondition,
|
||||
source: question.source ?? 'default',
|
||||
priorityWeight: question.priorityWeight ?? 1 }
|
||||
|
||||
return {
|
||||
...normalizedQuestion,
|
||||
test: (post: Post) => questionMatches (post, normalizedQuestion) }
|
||||
}
|
||||
|
||||
|
||||
export const storeGekanatorQuestion = (
|
||||
question: GekanatorQuestion): StoredGekanatorQuestion => ({
|
||||
id: question.condition.type === 'title-length-greater-than'
|
||||
? `title:length-at-least:${ question.condition.length + 1 }`
|
||||
: question.id,
|
||||
recordId: question.recordId,
|
||||
text: question.text,
|
||||
kind: question.kind,
|
||||
condition: normalizeTitleLengthCondition (question.condition),
|
||||
source: question.source,
|
||||
priorityWeight: question.priorityWeight,
|
||||
exampleAnswers: question.exampleAnswers })
|
||||
|
||||
|
||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
||||
return data.posts
|
||||
}
|
||||
|
||||
|
||||
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
||||
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
|
||||
return data.questions
|
||||
}
|
||||
|
||||
|
||||
export const fetchGekanatorExtraQuestions = async (
|
||||
gameId: number,
|
||||
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
|
||||
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||
`/gekanator/games/${ gameId }/extra_questions`,
|
||||
{ params: nonce ? { nonce } : undefined })
|
||||
return data.questions
|
||||
}
|
||||
|
||||
|
||||
export const buildGekanatorQuestions = (
|
||||
posts: Post[],
|
||||
options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
|
||||
const {
|
||||
includeTitleContains = true,
|
||||
tagQuestionCap = 192,
|
||||
titleContainsCap = 24,
|
||||
totalQuestionCap = Number.POSITIVE_INFINITY,
|
||||
} = options
|
||||
const tagCounts = countBy (posts.flatMap (post =>
|
||||
post.tags
|
||||
.filter (tag =>
|
||||
!(tag.category === 'meta')
|
||||
&& !(tag.name.includes ('タグ希望'))
|
||||
&& !(tag.name.includes ('bot操作')))
|
||||
.map (tag => tagQuestionKey (tag))))
|
||||
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
|
||||
const originalYears = countBy (
|
||||
posts
|
||||
.map (originalYearOf)
|
||||
.filter ((year): year is number => year != null))
|
||||
const originalMonths = countBy (
|
||||
posts
|
||||
.map (originalMonthOf)
|
||||
.filter ((month): month is number => month != null))
|
||||
const originalMonthDays = countBy (
|
||||
posts
|
||||
.map (originalMonthDayOf)
|
||||
.filter ((monthDay): monthDay is string => monthDay != null))
|
||||
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
||||
const titleWordCounts =
|
||||
includeTitleContains
|
||||
? countBy (
|
||||
posts.flatMap (post =>
|
||||
Array.from (
|
||||
new Set (
|
||||
(post.title ?? '')
|
||||
.match (
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu)
|
||||
?? []))))
|
||||
: new Map<string, number> ()
|
||||
|
||||
const usefulEntries = <T extends string | number> (
|
||||
counts: Map<T, number>,
|
||||
cap: number) =>
|
||||
[...counts.entries ()]
|
||||
.filter (([, count]) => count > 0 && count < posts.length)
|
||||
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
|
||||
- Math.abs (posts.length / 2 - b[1]))
|
||||
.slice (0, cap)
|
||||
|
||||
const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
|
||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, tagQuestionCap)
|
||||
.map (([key]) => {
|
||||
const { category, name } = tagFromQuestionKey (String (key))
|
||||
const label = category === 'nico' ? nicoTagLabel (name) : name
|
||||
|
||||
return {
|
||||
id: `tag:${ key }`,
|
||||
text: tagQuestionText (category, label),
|
||||
kind: 'tag' as const,
|
||||
condition: { type: 'tag' as const, key: String (key) },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => questionableTag (post, String (key)) }
|
||||
})
|
||||
|
||||
const sourceQuestions = usefulEntries (hosts, 20)
|
||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, 20)
|
||||
.map (([host]) => ({
|
||||
id: `source:${ host }`,
|
||||
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||
kind: 'source' as const,
|
||||
condition: { type: 'source' as const, host },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => hostOf (post) === host }))
|
||||
|
||||
const originalYearQuestions = usefulEntries (originalYears, 20)
|
||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, 20)
|
||||
.map (([year]) => ({
|
||||
id: `original-year:${ year }`,
|
||||
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-year' as const, year },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalYearOf (post) === year }))
|
||||
|
||||
const originalMonthQuestions = usefulEntries (originalMonths, 20)
|
||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, 20)
|
||||
.map (([month]) => ({
|
||||
id: `original-month:${ month }`,
|
||||
text: `オリジナルの投稿月は ${ month } 月?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-month' as const, month },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalMonthOf (post) === month }))
|
||||
|
||||
const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
|
||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, 20)
|
||||
.map (([monthDay]) => {
|
||||
const [month, day] = String (monthDay).split ('-')
|
||||
|
||||
return {
|
||||
id: `original-month-day:${ monthDay }`,
|
||||
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||||
kind: 'original_date' as const,
|
||||
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
||||
})
|
||||
|
||||
const titleQuestions = [
|
||||
{
|
||||
id: `title:length-at-least:${ titleLengthMedian }`,
|
||||
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
||||
kind: 'title' as const,
|
||||
condition: {
|
||||
type: 'title-length-at-least' as const,
|
||||
length: titleLengthMedian },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
|
||||
{
|
||||
id: 'title:ascii',
|
||||
text: '題名に英数字が混じってゐる?',
|
||||
kind: 'title' as const,
|
||||
condition: { type: 'title-has-ascii' as const },
|
||||
source: 'default' as const,
|
||||
priorityWeight: 1,
|
||||
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
||||
.filter (question => {
|
||||
const yes = posts.filter (post => question.test (post)).length
|
||||
const no = posts.length - yes
|
||||
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
|
||||
})
|
||||
const titleContainsQuestions =
|
||||
includeTitleContains
|
||||
? usefulEntries (titleWordCounts, titleContainsCap)
|
||||
.filter (([word, count]) =>
|
||||
String (word).length <= 24
|
||||
&& count >= 2
|
||||
&& count <= Math.max (2, posts.length * .7))
|
||||
.slice (0, titleContainsCap)
|
||||
.map (([word]) => ({
|
||||
id: `title:contains:${ word }`,
|
||||
text: `題名に「${ word }」が含まれる?`,
|
||||
kind: 'title' as const,
|
||||
condition: { type: 'title-contains' as const, text: String (word) },
|
||||
source: 'default' as const,
|
||||
priorityWeight: .96,
|
||||
test: (post: Post) => (post.title ?? '').includes (String (word)) }))
|
||||
: []
|
||||
|
||||
return [
|
||||
...sourceQuestions,
|
||||
...originalYearQuestions,
|
||||
...originalMonthQuestions,
|
||||
...originalMonthDayQuestions,
|
||||
...titleQuestions,
|
||||
...titleContainsQuestions,
|
||||
...tagQuestions].slice (0, totalQuestionCap)
|
||||
}
|
||||
|
||||
|
||||
export const saveGekanatorGame = async ({
|
||||
guessedPostId,
|
||||
correctPostId,
|
||||
answers,
|
||||
}: {
|
||||
guessedPostId: number
|
||||
correctPostId: number
|
||||
answers: GekanatorAnswerLog[]
|
||||
}): Promise<{ id: number; learnedExampleCount: number }> =>
|
||||
await apiPost ('/gekanator/games', {
|
||||
guessed_post_id: guessedPostId,
|
||||
correct_post_id: correctPostId,
|
||||
answers: answers.map (answer => ({
|
||||
question_id: answer.questionId,
|
||||
question_text: answer.questionText,
|
||||
question_condition: answer.questionCondition ?? null,
|
||||
question_mode: answer.questionMode,
|
||||
question_purpose: answer.questionPurpose,
|
||||
effective_question: answer.effectiveQuestion,
|
||||
learning_question: answer.learningQuestion,
|
||||
answer: answer.answer,
|
||||
original_answer: answer.originalAnswer })) })
|
||||
|
||||
|
||||
export const saveGekanatorQuestionSuggestion = async ({
|
||||
gekanatorGameId,
|
||||
existingQuestionId,
|
||||
questionText,
|
||||
answer,
|
||||
}: {
|
||||
gekanatorGameId: number
|
||||
existingQuestionId?: number
|
||||
questionText?: string
|
||||
answer: GekanatorAnswerValue
|
||||
}): Promise<{ id: number; count: number }> =>
|
||||
await apiPost ('/gekanator/question_suggestions', {
|
||||
gekanator_game_id: gekanatorGameId,
|
||||
existing_question_id: existingQuestionId,
|
||||
question_text: questionText,
|
||||
answer })
|
||||
|
||||
|
||||
export const saveGekanatorExtraQuestionAnswers = async ({
|
||||
gameId,
|
||||
answers,
|
||||
}: {
|
||||
gameId: number
|
||||
answers: { questionId: number; answer: GekanatorAnswerValue }[]
|
||||
}) =>
|
||||
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
|
||||
answers: answers.map (item => ({
|
||||
question_id: item.questionId,
|
||||
answer: item.answer })) })
|
||||
変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示
新しい課題から参照
ユーザをブロックする