コミットを比較

..

1 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 257731168f #329 2026-04-27 12:40:06 +09:00
281個のファイルの変更1685行の追加25433行の削除
-35
ファイルの表示
@@ -1,35 +0,0 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
-307
ファイルの表示
@@ -1,307 +0,0 @@
# AGENTS.md
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
- Backend: Rails API under `backend/`.
- Frontend: React + TypeScript + Vite under `frontend/`.
- Docs: lightweight command notes under `docs/`.
- There is no README or Makefile at the repository root as of this inspection.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording,
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
- `backend/lib/tasks`: custom Rake tasks.
- `backend/spec`: RSpec tests.
- `backend/test`: Rails minitest files that still exist in the tree.
- `frontend/src/App.tsx`: frontend route definitions and initial user setup.
- `frontend/src/pages`: page-level React components.
- `frontend/src/components`: shared and feature components.
- `frontend/src/lib`: API client helpers, query keys, prefetchers, and domain helpers.
- `frontend/src/stores`: Zustand stores.
- `docs/commands.md`: command notes.
## Commands
Only list commands that are backed by files inspected in this repository.
### Backend
The following binstubs exist under `backend/bin`:
```sh
cd backend
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bin/kamal
bin/thrust
```
Common Rails/Rake usage through existing binstubs:
```sh
cd backend
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
bin/rake
bin/rubocop
bin/brakeman
```
RSpec is present in `Gemfile` and `.rspec` exists:
```sh
cd backend
bundle exec rspec
```
### Frontend
The following npm scripts exist in `frontend/package.json`:
```sh
cd frontend
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`node scripts/generate-sitemap.js`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
## Coding style
- Prefer precise, minimal changes.
- Do not flatter or over-explain.
- Explain risks directly.
- 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 `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- 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 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.
- For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- 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.
- 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
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- A leading tab is exactly equivalent to 8 leading spaces.
- 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
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
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:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as
`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 a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
A task is complete only when:
- implementation is complete,
- relevant non-test 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.
-225
ファイルの表示
@@ -1,225 +0,0 @@
# backend/AGENTS.md
## Scope
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands
Use commands backed by files and dependencies in this directory:
```sh
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bundle exec rspec
```
Common checks:
```sh
bundle exec rspec
bin/rubocop
bin/brakeman
```
Common Rails commands:
```sh
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
```
After backend behavior changes, run the relevant RSpec files. For broad backend changes, run:
```sh
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.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit,
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Ruby style
- 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
dense.
- For Ruby arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
## BAN and IP BAN
- `ApplicationController` runs these before actions in order:
- `reject_banned_ip_address!`
- `authenticate_user`
- `reject_banned_user!`
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `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.
## RSpec
- Prefer RSpec for new backend tests.
- Put API behavior coverage under `spec/requests`.
- Put model behavior under `spec/models`.
- Put service behavior under `spec/services`.
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `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.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless
explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency,
skips unchanged update snapshots, creates the next version row, and updates
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.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges,
viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent
implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts.
- Frontend code expects camelCase conversion client-side, while Rails params
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- Be sensitive to N+1 queries in all backend work.
- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when
you find them in the code path you are editing.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
-2
ファイルの表示
@@ -69,5 +69,3 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
-4
ファイルの表示
@@ -306,9 +306,6 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
@@ -480,7 +477,6 @@ DEPENDENCIES
puma (>= 5.0)
rack-cors
rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0)
rubocop-rails-omakase
sprockets-rails
+3 -66
ファイルの表示
@@ -1,19 +1,14 @@
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
def current_user = @current_user
def current_user
@current_user
end
private
def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
@@ -27,62 +22,4 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes'])
end
end
def render_bad_request message = 'リクエストが不正です.'
render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end
def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_validation_error(fields: field ? { field => [message] } : { },
base: field ? [] : [message])
end
def render_record_invalid error
render_validation_error error.record
end
def render_record_not_unique _error = nil
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
record.errors.each do |error|
errors[error.attribute] ||= []
errors[error.attribute] << error.message
end
end
fields.each do |attr, messages|
errors[attr.to_sym] ||= []
errors[attr.to_sym].concat(Array(messages))
end
base_errors = Array(base) + Array(errors.delete(:base))
render json: { type: 'validation_error',
message: '入力内容を確認してください.',
errors:,
base_errors: },
status:
end
end
+3 -7
ファイルの表示
@@ -2,8 +2,7 @@ class DeerjikistsController < ApplicationController
def show
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
return head :bad_request if platform.blank? || code.blank?
deerjikist = Deerjikist
.joins(:tag)
@@ -23,9 +22,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.') if tag_id <= 0
return head :bad_request if platform.blank? || code.blank? || tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id
@@ -41,8 +38,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
return head :bad_request if platform.blank? || code.blank?
Deerjikist.find([platform, code]).destroy!
-272
ファイルの表示
@@ -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
-71
ファイルの表示
@@ -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
-95
ファイルの表示
@@ -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
-147
ファイルの表示
@@ -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
+4 -12
ファイルの表示
@@ -40,11 +40,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
file = params[: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
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
@@ -58,7 +54,7 @@ class MaterialsController < ApplicationController
if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render_validation_error material
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end
@@ -72,11 +68,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
file = params[: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
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
@@ -92,7 +84,7 @@ class MaterialsController < ApplicationController
if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render_validation_error material
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end
+19 -82
ファイルの表示
@@ -1,69 +1,26 @@
class NicoTagsController < ApplicationController
def index
name = params[:name].presence
linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
.order(updated_at: :desc)
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
count = q.count
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
tags = q.limit(limit + 1).to_a
next_cursor = nil
if tags.size > limit
next_cursor = tags.last.updated_at.iso8601(6)
tags = tags.first(limit)
end
render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: }
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
TagRepr.base(lt)
})
}, next_cursor: }
end
def update
@@ -73,18 +30,14 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split
linked_tags = nil
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags
@@ -94,21 +47,5 @@ class NicoTagsController < ApplicationController
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end
+33 -407
ファイルの表示
@@ -44,8 +44,7 @@ class PostsController < ApplicationController
filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.preload(tags: [:materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -96,9 +95,7 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
return head :not_found unless post
@@ -107,25 +104,12 @@ class PostsController < ApplicationController
end
def show
post =
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.find_by(id: params[:id])
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post
parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a
child_posts = post.children.with_attached_thumbnail.order(:id).to_a
sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id))
related = post.related(limit: 20).to_a
render json: PostRepr.detail(post, current_user,
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post))
render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20))
end
def create
@@ -139,39 +123,28 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) if thumbnail.present?
post.thumbnail.attach(thumbnail)
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
tags = Tag.normalise_tags(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end
post.reload
render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
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
render_post_form_record_invalid e.record
head :bad_request
end
def viewed
@@ -192,78 +165,35 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
force = bool?(:force)
merge = bool?(:merge)
return render_bad_request('force と merge は同時に指定できません.') if force && merge
base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
title = params[:title].presence
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = nil
conflict_json = nil
post = Post.find(params[:id].to_i)
ApplicationRecord.transaction do
post = Post.lock.find(params[:id].to_i)
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
post.update!(title:, original_created_from:, original_created_before:)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
parent_post_ids:)
normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
snapshot_to_apply =
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
incoming_snapshot
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end
apply_post_snapshot!(post, snapshot_to_apply)
tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post)
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
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
render_post_form_record_invalid e.record
head :bad_request
end
def changes
@@ -281,7 +211,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
tag: [:materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
@@ -358,7 +288,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags, sections
def sync_post_tags! post, desired_tags
desired_tags.each do |t|
t.save! if t.new_record?
end
@@ -377,20 +307,13 @@ class PostsController < ApplicationController
end
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for post
tags = post.tags.reject(&:deprecated?).to_a
def build_tag_tree_for tags
tags = tags.to_a
tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -405,11 +328,6 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id)
sections_by_tag_id =
PostTagSection
.where(post_id: post.id, tag_id: tag_ids)
.order(:begin_ms)
.group_by(&:tag_id)
memo = { }
@@ -417,11 +335,8 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id]
return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
.as_json(only: [:begin_ms, :end_ms])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [], sections:)
return TagRepr.base(tag).merge(children: [])
end
if memo.key?(tag_id)
@@ -433,298 +348,9 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
memo[tag_id] = TagRepr.base(tag).merge(children:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
end
def sibling_posts_by_parent parent_post_ids
return { } if parent_post_ids.blank?
implications =
PostImplication
.where(parent_post_id: parent_post_ids)
.includes(post: { thumbnail_attachment: :blob })
.order(:parent_post_id, :post_id)
implications.group_by(&:parent_post_id).transform_values { |items|
items.map(&:post)
}
end
def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if !(id) || id <= 0
id
}.uniq
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
raise ActiveRecord::RecordInvalid, post
end
existing_ids = Post.where(id: parent_post_ids).pluck(:id)
missing_ids = parent_post_ids - existing_ids
if missing_ids.present?
post.errors.add :parent_post_ids,
"存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post
end
current_ids = post.parent_posts.pluck(:id)
ids_to_add = parent_post_ids - current_ids
ids_to_remove = current_ids - parent_post_ids
PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
ids_to_add.each do |parent_post_id|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
end
end
def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false)
if version_no&.positive?
version_no
else
nil
end
end
def post_snapshot_from_version version
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end
def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
end
def post_snapshot_from_record post
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
{ title:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
parent_post_ids: parent_post_ids.sort }
end
def snapshot_parent_post_ids_from_version version
if version.respond_to?(:parent_post_ids)
version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
elsif version.respond_to?(:parent_id) && version.parent_id
[version.parent_id]
else
[]
end
end
def snapshot_time value
return nil if value.blank?
value = Time.zone.parse(value.to_s) if value in String
value&.in_time_zone&.iso8601(6)
rescue ArgumentError, TypeError
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
Tag.normalise_tags!(raw_tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
Tag.expand_parent_tags(tags).reject(&:deprecated?).uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort
end
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ Post.ms_to_time(section[1]) }]"
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
base_version_no:,
current_version_no: post.version_no,
base: base_snapshot,
current: current_snapshot,
mine: incoming_snapshot,
changes:,
conflicts:,
mergeable: conflicts.empty? }
end
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:parent_post_ids, '親投稿',
base_snapshot, current_snapshot, incoming_snapshot)].compact
end
def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field]
current = current_snapshot[field]
mine = incoming_snapshot[field]
return nil if current == base && mine == base
{ field:, label:, base:, current:, mine:,
changed_by_current: current != base,
changed_by_me: mine != base,
conflict: scalar_snapshot_conflict?(base, current, mine) }
end
def scalar_snapshot_conflict? base, current, mine
current != base && mine != base && current != mine
end
def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field].to_a
current = current_snapshot[field].to_a
mine = incoming_snapshot[field].to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
if (added_by_current.empty? &&
removed_by_current.empty? &&
added_by_me.empty? &&
removed_by_me.empty?)
return nil
end
{ field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:,
changed_by_current: added_by_current.present? || removed_by_current.present?,
changed_by_me: added_by_me.present? || removed_by_me.present?,
conflict: set_snapshot_conflict?(added_by_current:,
removed_by_current:,
added_by_me:,
removed_by_me:) }
end
def set_snapshot_conflict? added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine
raise ArgumentError, '競合してゐる項目はマージできません.'
end
def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me
merged.uniq.sort
end
def render_post_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end
+4 -8
ファイルの表示
@@ -4,7 +4,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user
url = params[:url]
return render_bad_request('URL は必須です.') unless url.present?
return head :bad_request unless url.present?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
@@ -16,7 +16,7 @@ class PreviewController < ApplicationController
render json: { title: title }
rescue => e
render_bad_request(e.message)
render json: { error: e.message }, status: :bad_request
end
def thumbnail
@@ -25,7 +25,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user
url = params[:url]
return render_bad_request('URL は必須です.') if url.blank?
return head :bad_request if url.blank?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
@@ -40,11 +40,7 @@ class PreviewController < ApplicationController
File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline'
else
render json: { type: 'internal_server_error',
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
render json: { error: 'Failed to generate thumbnail' }, status: :internal_server_error
end
end
end
+4 -6
ファイルの表示
@@ -5,12 +5,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id]
child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
return head :bad_request if parent_id.blank? || child_id.blank?
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -28,12 +27,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id]
child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
return head :bad_request if parent_id.blank? || child_id.blank?
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
-3
ファイルの表示
@@ -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,
+37 -206
ファイルの表示
@@ -1,8 +1,3 @@
require 'net/http'
require 'uri'
require 'set'
class TagsController < ApplicationController
def index
post_id = params[:post]
@@ -15,8 +10,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 +44,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]
@@ -83,27 +73,37 @@ class TagsController < ApplicationController
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0
graph = build_with_depth_graph
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
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: visible_child_tag_ids(tag.id, graph).present?,
children: [])
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end
@@ -129,7 +129,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 =
@@ -165,7 +164,7 @@ class TagsController < ApplicationController
def show_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -183,52 +182,24 @@ class TagsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
render json: DeerjikistRepr.many(tag.deerjikists)
end
def deerjikists_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end
def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
return head :not_found unless tag
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
render json: DeerjikistRepr.many(tag.deerjikists)
end
def materials_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -247,26 +218,21 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip
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)
return head :unprocessable_entity if name.blank? || category.blank?
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 json: { error: 'システム・タグの名称は変更できません.' },
status: :unprocessable_entity
end
if tag.nico? || category == 'nico'
return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
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)
@@ -275,11 +241,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
@@ -307,17 +269,12 @@ 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 json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end
ApplicationRecord.transaction do
@@ -329,9 +286,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
@@ -348,93 +302,6 @@ class TagsController < ApplicationController
private
def build_with_depth_graph
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
children_by_parent_id[parent_id] << child_id
parent_ids_by_child_id[child_id] << parent_id
end
tag_ids = (children_by_parent_id.keys +
parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
tags_by_id = Tag.where(id: tag_ids)
.pluck(:id, :category, :deprecated_at)
.each_with_object({ }) do |(id, category, deprecated_at), h|
h[id] = { category:, deprecated: deprecated_at.present? }
end
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
visible_child_tag_ids_by_parent_id: { } }
end
def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, attrs|
next unless with_depth_visible_tag?(attrs)
next unless visible_root_tag?(tag_id, graph)
tag_id
end
end
def visible_root_tag? tag_id, graph
seen = Set.new([tag_id])
stack = graph[:parent_ids_by_child_id][tag_id].dup
until stack.empty?
parent_id = stack.pop
next if seen.include?(parent_id)
seen << parent_id
parent = graph[:tags_by_id][parent_id]
next unless parent
return false unless parent[:deprecated]
stack.concat(graph[:parent_ids_by_child_id][parent_id])
end
true
end
def visible_child_tag_ids parent_tag_id, graph
cache = graph[:visible_child_tag_ids_by_parent_id]
return cache[parent_tag_id] if cache.key?(parent_tag_id)
visible_ids = Set.new
graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id]))
end
cache[parent_tag_id] = visible_ids.to_a
end
def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen
return if seen.include?(tag_id)
seen = seen.dup << tag_id
tag = graph[:tags_by_id][tag_id]
return unless tag
if tag[:deprecated]
graph[:children_by_parent_id][tag_id].each do |child_tag_id|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen)
end
return
end
visible_ids << tag_id if with_depth_visible_tag?(tag)
end
def with_depth_visible_tag? tag
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
end
def build_tag_children tag
material = tag.materials.first
file = nil
@@ -507,9 +374,9 @@ class TagsController < ApplicationController
end
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
old_parent_tags = tag.parents.to_a
@@ -524,40 +391,4 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:)
end
end
def normalise_deerjikist_code platform, code
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
html = Net::HTTP.get(URI(url))
canonical = html[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical
html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end
+3 -25
ファイルの表示
@@ -1,28 +1,21 @@
class TheatreCommentsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt < 0
no_gt = 0 if no_gt.negative?
comments = TheatreComment
.where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt)
.order(no: :desc)
.limit(limit)
render json: comments.map {
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
render json: comments.as_json(include: { user: { only: [:id, :name] } })
end
def create
return head :unauthorized unless current_user
content = params[:content]
return render_unprocessable_entity('本文は必須です.', field: :content) if content.blank?
return head :unprocessable_entity if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre
@@ -36,19 +29,4 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created
end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
return head :forbidden unless comment.user == current_user
comment.discard!
head :no_content
end
end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreProgrammesController < ApplicationController
def index
limit = params[:limit].to_i
limit = 100 if limit <= 0
position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(:post)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.map { |programme|
programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
}
end
end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+8 -113
ファイルの表示
@@ -31,7 +31,9 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at
end
render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:)
render json: {
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end
def next_post
@@ -41,119 +43,12 @@ class TheatresController < ApplicationController
return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do
theatre.lock!
TheatrePostAdvancer.call(theatre:)
end
post = Post.where("url LIKE '%nicovideo.jp%'")
.or(Post.where("url LIKE '%youtube.com%'"))
.order('RAND()')
.first
theatre.update!(current_post: post, current_post_started_at: Time.current)
head :no_content
end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
if theatre.current_post_id != requested_post_id
conflicted = true
next
end
TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
theatre.with_lock do
if theatre.current_post
if theatre.current_post_id != requested_post_id
conflicted = true
else
TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end
+7 -3
ファイルの表示
@@ -1,6 +1,9 @@
class UsersController < ApplicationController
def create
return head :unprocessable_entity if request.remote_ip.blank?
user = nil
User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
@@ -14,7 +17,8 @@ class UsersController < ApplicationController
def verify
user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user
return head :forbidden if user.banned?
return head :unprocessable_entity if request.remote_ip.blank?
attach_ip_address!(user)
@@ -42,12 +46,12 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i
name = params[:name]
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
return head :bad_request if name.blank?
if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else
render_validation_error user
render json: user.errors, status: :unprocessable_entity
end
end
+13 -21
ファイルの表示
@@ -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
@@ -47,17 +46,17 @@ class WikiPagesController < ApplicationController
def diff
id = params[:id]
return render_bad_request('id は必須です.') if id.blank?
return head :bad_request if id.blank?
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
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return render_unprocessable_entity('差分を表示できない版です.')
return head :unprocessable_entity
end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -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 }
@@ -91,8 +89,7 @@ class WikiPagesController < ApplicationController
body = params[:body].to_s
message = params[:message].presence
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
return head :unprocessable_entity if title.blank? || body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
@@ -104,10 +101,8 @@ class WikiPagesController < ApplicationController
message:)
render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
head :unprocessable_entity
end
def update
@@ -117,8 +112,7 @@ class WikiPagesController < ApplicationController
title = params[:title]&.strip
body = params[:body].to_s
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
return head :unprocessable_entity if title.blank? || body.blank?
page = WikiPage.find(params[:id])
base_revision_id = params[:base_revision_id].presence
@@ -159,7 +153,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 +161,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,
-21
ファイルの表示
@@ -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
-15
ファイルの表示
@@ -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
-23
ファイルの表示
@@ -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
-97
ファイルの表示
@@ -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
-12
ファイルの表示
@@ -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
+1 -4
ファイルの表示
@@ -1,10 +1,7 @@
class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }
has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end
+7 -78
ファイルの表示
@@ -1,48 +1,20 @@
class Post < ApplicationRecord
require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :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',
foreign_key: :post_id,
dependent: :destroy,
inverse_of: :post
has_many :parents, through: :parent_post_implications, source: :parent_post
has_many :child_post_implications,
class_name: 'PostImplication',
foreign_key: :parent_post_id,
dependent: :destroy,
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
@@ -50,59 +22,16 @@ class Post < ApplicationRecord
validate :validate_original_created_range
validate :url_must_be_http_url
def parent_posts = parents
def child_posts = children
def sibling_posts
parent_post_ids = parent_posts.order(:id).pluck(:id)
parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
end
def as_json options = { }
super(options).merge(thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) :
nil)
super(options).merge({ thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) :
nil })
rescue
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ Post.ms_to_time(section.end_ms) }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def related limit: nil
ids = post_similarities.order(cos: :desc)
@@ -138,7 +67,7 @@ class Post < ApplicationRecord
return if !(f) || !(b)
if f >= b
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class PostImplication < ApplicationRecord
self.primary_key = :post_id, :parent_post_id
belongs_to :post, inverse_of: :parent_post_implications
belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications
validates :post_id, presence: true, uniqueness: { scope: :parent_post_id }
validates :parent_post_id, presence: true
validate :parent_post_mustnt_be_itself
private
def parent_post_mustnt_be_itself
if parent_post_id == post_id
errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.'
end
end
end
-6
ファイルの表示
@@ -10,12 +10,6 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
-20
ファイルの表示
@@ -1,20 +0,0 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, presence: true,
numericality: { only_integer: true, greater_than: :begin_ms }
end
+5 -80
ファイルの表示
@@ -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'
@@ -49,8 +40,6 @@ class Tag < ApplicationRecord
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
@@ -67,7 +56,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,72 +75,34 @@ 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
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false,
with_sections: false
def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3')
name, *section_raw = n.split
begin_ms, end_ms = section_raw.map { time_to_ms(_1) }
next if begin_ms == end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
sections_by_tag << [begin_ms, end_ms]
end
name = TagName.canonicalise(name).first
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
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
tags.uniq(&:id)
end
def self.expand_parent_tags tags
@@ -273,29 +223,4 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end
end
def self.time_to_ms str
parts = str.split(':')
s_part = parts.pop
s, ms = s_part.split('.')
total_s = s.to_i
if parts.length >= 1
total_s += parts.pop.to_i * 60
end
if parts.length >= 1
total_s += parts.pop.to_i * 3_600
end
total_s * 1_000 + ms.to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end
-4
ファイルの表示
@@ -7,10 +7,6 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User'
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+1 -5
ファイルの表示
@@ -4,6 +4,7 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }
has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -18,10 +19,5 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin?
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end
-3
ファイルの表示
@@ -15,14 +15,11 @@ class WikiPage < ApplicationRecord
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name
validates :tag_name, presence: true
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
+5 -51
ファイルの表示
@@ -2,65 +2,19 @@
module PostRepr
BASE_FIELDS = [
:id,
:version_no,
:url,
:title,
:thumbnail_base,
:original_created_from,
:original_created_before,
:created_at,
:updated_at
].freeze
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
module_function
def base post, current_user = nil
json = common(post)
json['tags'] = tag_json(post.tags)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
json = post.as_json(BASE)
return json.merge(viewed: false) unless current_user
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: []
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts
posts.map { |post| card(post) }
viewed = current_user.viewed?(post)
json.merge(viewed:)
end
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
end
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end
+2 -6
ファイルの表示
@@ -2,8 +2,8 @@
module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze
module_function
@@ -12,9 +12,5 @@ module TagRepr
parents: tag.parents.map { _1.as_json(BASE) })
end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) }
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@
module WikiPageRepr
BASE = { methods: [:title, :deprecated_at] }.freeze
BASE = { methods: [:title] }.freeze
module_function
-22
ファイルの表示
@@ -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
-168
ファイルの表示
@@ -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
-52
ファイルの表示
@@ -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 -1
ファイルの表示
@@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
parent_id: @record.parent_id,
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end
+2 -3
ファイルの表示
@@ -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 = { }
-1
ファイルの表示
@@ -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
-29
ファイルの表示
@@ -1,29 +0,0 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
-119
ファイルの表示
@@ -1,119 +0,0 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
ELIGIBLE_POST_URL_CONDITION =
["url LIKE '%nicovideo.jp%'",
"url LIKE '%youtube.com/watch%'",
"url LIKE '%youtu.be/%'"]
.join(' OR ')
def initialize theatre:
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where(ELIGIBLE_POST_URL_CONDITION)
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: light_tag_json(tag),
penalty: }
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json candidates
candidates.map { |candidate|
{ post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
end
def light_post_json post
{ id: post.id,
title: post.title,
url: post.url }
end
def light_tag_json tag
{ id: tag.id,
name: tag.name,
category: tag.category }
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+10 -35
ファイルの表示
@@ -16,20 +16,19 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version
validate_version_sequence!(latest)
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
attrs = snapshot_attributes
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
end
end
@@ -46,31 +45,7 @@ class VersionRecorder
created_by_user: @created_by_user }
end
def update_record_version_no! version_no
@record.update_columns(version_no:)
@record.version_no = version_no
end
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
return unless latest
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
"but latest #{ version_class.name } version_no is #{ latest.version_no }")
end
end
def same_snapshot? version, attrs
attrs.all? { |k, v| version.public_send(k) == v }
end
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
def validate_event_type!
return if EVENT_TYPES.include?(@event_type)
-73
ファイルの表示
@@ -1,73 +0,0 @@
require 'json'
require 'net/http'
require 'uri'
module Youtube
class ApiClient
ENDPOINT = 'https://www.googleapis.com/youtube/v3'
def initialize api_key: ENV.fetch('YOUTUBE_API_KEY')
@api_key = api_key
end
def search_videos q:, published_after: nil, published_before: nil, page_token: nil
get_json('/search', {
part: 'snippet',
type: 'video',
q:,
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after&.iso8601,
publishedBefore: published_before&.iso8601,
pageToken: page_token }.compact)
end
def videos ids
return { 'items' => [] } if ids.empty?
get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(','))
end
def playlist_items playlist_id:, page_token: nil
get_json('/playlistItems', {
part: 'snippet,contentDetails,status',
playlistId: playlist_id,
maxResults: 50,
pageToken: page_token }.compact)
end
def channel id: nil, handle: nil
raise ArgumentError, 'id or handle is required' if id.present? == handle.present?
params = { part: 'snippet,contentDetails' }
params[:id] = id if id.present?
params[:forHandle] = handle if handle.present?
get_json('/channels', params)
end
private
def get_json path, params
uri = URI(ENDPOINT + path)
uri.query = URI.encode_www_form(params.merge(key: @api_key))
response = Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30) do |http|
http.get(uri)
end
unless response.is_a?(Net::HTTPSuccess)
raise "YouTube API error: #{ response.code } #{ response.body }"
end
JSON.parse(response.body)
end
end
end
-168
ファイルの表示
@@ -1,168 +0,0 @@
require 'open-uri'
require 'set'
require 'time'
module Youtube
class Sync
def initialize client: ApiClient.new
@client = client
end
def sync!
video_ids = discover_video_ids
return if video_ids.empty?
video_ids.each_slice(50) do |ids|
@client.videos(ids).fetch('items', []).each do |item|
sync_video!(VideoItem.new(item))
end
end
end
private
def discover_video_ids
ids = Set.new
query_terms.each do |q|
response = @client.search_videos(q:, published_after: sync_since)
response.fetch('items', []).each do |item|
video_id = item.dig('id', 'videoId')
ids << video_id if video_id.present?
end
end
playlist_ids.each do |playlist_id|
each_playlist_item(playlist_id) do |item|
video_id = item.dig('contentDetails', 'videoId')
video_id ||= item.dig('snippet', 'resourceId', 'videoId')
ids << video_id if video_id.present?
end
end
ids.to_a
end
def sync_video! video
post = Post.where('url REGEXP ?', youtube_url_regexp(video.id)).first
original_created_from = video.published_at.change(sec: 0)
original_created_before = original_created_from + 1.minute
post_created = false
post_changed = false
if post
post.assign_attributes(title: video.title,
original_created_from:,
original_created_before:,
thumbnail_base: video.thumbnail_url)
post_changed = post.changed?
post.save! if post_changed
attach_thumbnail_if_needed!(post, video.thumbnail_url)
else
post_created = true
post = Post.create!(
title: video.title,
url: video.url,
thumbnail_base: video.thumbnail_url,
uploaded_user_id: nil,
original_created_from:,
original_created_before:)
attach_thumbnail_if_needed!(post, video.thumbnail_url)
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.youtube.id, Tag.video.id])
end
kept_tag_ids = post.tags.pluck(:id).to_set
desired_tag_ids = kept_tag_ids.to_a
deerjikist = Deerjikist.find_by(platform: :youtube, code: video.channel_id)
if deerjikist
desired_tag_ids.delete(Tag.no_deerjikist.id)
desired_tag_ids << deerjikist.tag_id
elsif post.tags.where(category: :deerjikist).none?
desired_tag_ids << Tag.no_deerjikist.id
end
desired_tag_ids.uniq!
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
to_remove = current_tag_ids - desired_tag_ids
Tag.where(id: to_add.to_a).find_each do |tag|
begin
PostTag.create!(post:, tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end
def attach_thumbnail_if_needed! post, thumbnail_url
return if post.thumbnail.attached?
return if thumbnail_url.blank?
post.thumbnail.attach(
io: URI.open(thumbnail_url),
filename: File.basename(URI.parse(thumbnail_url).path),
content_type: 'image/jpeg')
post.resized_thumbnail!
end
def youtube_url_regexp id
escaped = Regexp.escape(id)
"(youtube\\.com/watch\\?v=#{ escaped }|youtu\\.be/#{ escaped })([^A-Za-z0-9_-]|$)"
end
def query_terms = ['ぼざろクリーチャーシリーズ', '伊地知ニジカ', '伊地知虹鹿']
def playlist_ids
['PLrOch4zHkI5vu29b-f9umUQQ4tQkuWLPX',
'PLrOch4zHkI5vOK0RaytQq6PbucxQkkL0K',
'PLrOch4zHkI5tdwm9vSegiDQJOM-hgpcOC']
end
def sync_since = 14.days.ago
def each_playlist_item playlist_id
page_token = nil
loop do
response = @client.playlist_items(playlist_id:, page_token:)
response.fetch('items', []).each do |item|
yield item
end
page_token = response['nextPageToken']
break if page_token.blank?
end
end
end
end
-32
ファイルの表示
@@ -1,32 +0,0 @@
require 'time'
module Youtube
class VideoItem
attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags
def initialize item
snippet = item.fetch('snippet')
@id = item.fetch('id')
@title = snippet['title']
@channel_id = snippet['channelId']
@published_at = Time.iso8601(snippet['publishedAt'])
@thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { })
@raw_tags = snippet['tags'] || []
end
def url = "https://www.youtube.com/watch?v=#{ @id }"
private
def pick_thumbnail thumbnails
['maxres', 'standard', 'high', 'medium', 'default'].each do |key|
url = thumbnails.dig(key, 'url')
return url if url.present?
end
nil
end
end
end
+1 -25
ファイルの表示
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
patch '', action: :update
get :deerjikists
put :deerjikists, action: :update_deerjikists
end
end
@@ -63,24 +62,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
@@ -103,14 +84,9 @@ Rails.application.routes.draw do
member do
put :watching
patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end
resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
resources :comments, controller: :theatre_comments, only: [:index, :create]
end
resources :materials, only: [:index, :show, :create, :update, :destroy]
-8
ファイルの表示
@@ -17,11 +17,3 @@ every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production'
rake 'tag_similarity:calc', environment: 'production'
end
every 1.day, at: '7:50 am' do
rake 'nico:export', environment: 'production'
end
every :hour do
rake 'post:sync', environment: 'production'
end
-24
ファイルの表示
@@ -1,24 +0,0 @@
class CreatePostImplications < ActiveRecord::Migration[8.0]
def up
create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :parent_post, null: false, foreign_key: { to_table: :posts }
t.timestamps
t.check_constraint 'post_id <> parent_post_id',
name: 'chk_post_implications_no_self'
end
add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id
remove_column :post_versions, :parent_id, :bigint
remove_reference :posts, :parent, foreign_key: { to_table: :posts }
end
def down
add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base
add_column :post_versions, :parent_id, :bigint, after: :post_id
remove_column :post_versions, :parent_post_ids, :text
drop_table :post_implications
end
end
@@ -1,16 +0,0 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end
def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
class AddVersionNoToPosts < ActiveRecord::Migration[8.0]
def up
add_column :posts, :version_no, :integer
execute <<~SQL
UPDATE
posts
SET
version_no = (
SELECT
MAX(version_no)
FROM
post_versions
WHERE
post_id = posts.id)
SQL
change_column_null :posts, :version_no, false
add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive'
end
def down
remove_check_constraint :posts, name: 'chk_posts_version_no_positive'
remove_column :posts, :version_no
end
end
-37
ファイルの表示
@@ -1,37 +0,0 @@
class AddVersionNoToTags < ActiveRecord::Migration[8.0]
def up
add_column :tags, :version_no, :integer
execute <<~SQL
UPDATE
tags
SET
version_no = (
CASE category
WHEN 'nico' THEN
(SELECT
MAX(version_no)
FROM
nico_tag_versions
WHERE
tag_id = tags.id)
ELSE
(SELECT
MAX(version_no)
FROM
tag_versions
WHERE
tag_id = tags.id)
END)
SQL
change_column_null :tags, :version_no, false
add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive'
end
def down
remove_check_constraint :tags, name: 'chk_tags_version_no_positive'
remove_column :tags, :version_no
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0]
def up
add_column :wiki_pages, :version_no, :integer
execute <<~SQL
UPDATE
wiki_pages
SET
version_no = (
SELECT
MAX(version_no)
FROM
wiki_versions
WHERE
wiki_page_id = wiki_pages.id)
SQL
change_column_null :wiki_pages, :version_no, false
add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive'
end
def down
remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive'
remove_column :wiki_pages, :version_no
end
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class CreateTheatreProgrammes < ActiveRecord::Migration[8.0]
def change
create_table :theatre_programmes, primary_key: [:theatre_id, :position] do |t|
t.references :theatre, null: false, foreign_key: true
t.integer :position, null: false
t.references :post, null: false, foreign_key: true
t.datetime :created_at, null: false
end
end
end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: false
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
-9
ファイルの表示
@@ -1,9 +0,0 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
-36
ファイルの表示
@@ -1,36 +0,0 @@
class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
create_table :theatre_skip_events do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :skipped_by_user, null: false, foreign_key: { to_table: :users }
t.integer :programme_position
t.datetime :created_at, null: false
end
create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
end
create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
end
add_index :theatre_skip_events, [:theatre_id, :created_at]
add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at],
name: 'idx_theatre_skip_votes_theatre_post_created'
add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_voters_user_event'
add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_tags_tag_event'
end
end
-18
ファイルの表示
@@ -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
-14
ファイルの表示
@@ -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
-19
ファイルの表示
@@ -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
-13
ファイルの表示
@@ -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
-19
ファイルの表示
@@ -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
@@ -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
-20
ファイルの表示
@@ -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
生成ファイル
+9 -192
ファイルの表示
@@ -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_21_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) 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,85 +48,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_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"
t.boolean "banned", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end
@@ -193,15 +119,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
@@ -210,19 +127,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
@@ -251,12 +155,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.text "parent_post_ids", null: false
t.bigint "parent_id"
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["parent_id"], name: "index_post_versions_on_parent_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
@@ -267,18 +172,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.string "title"
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id"
t.datetime "created_at", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
t.integer "video_ms"
t.index ["parent_id"], name: "index_posts_on_parent_id"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -335,7 +237,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_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
@@ -353,14 +254,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_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
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -376,55 +272,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["user_id"], name: "index_theatre_comments_on_user_id"
end
create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.integer "position", null: false
t.bigint "post_id", null: false
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_programmes_on_post_id"
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end
create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "tag_id", null: false
t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event"
t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id"
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id"
end
create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "user_id", null: false
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id"
t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event"
t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id"
end
create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "skipped_by_user_id", null: false
t.integer "programme_position"
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_skip_events_on_post_id"
t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id"
t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at"
t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id"
end
create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id"
t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created"
t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id"
t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id"
end
create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "user_id", null: false
@@ -432,7 +279,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
@@ -480,10 +326,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.string "name"
t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false
t.datetime "banned_at"
t.boolean "banned", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -516,12 +361,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -572,18 +415,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_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_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
@@ -597,18 +428,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "posts", column: "parent_id"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags"
@@ -621,18 +450,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_skip_event_tags", "tags"
add_foreign_key "theatre_skip_event_tags", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "users"
add_foreign_key "theatre_skip_events", "posts"
add_foreign_key "theatre_skip_events", "theatres"
add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id"
add_foreign_key "theatre_skip_votes", "posts"
add_foreign_key "theatre_skip_votes", "theatres"
add_foreign_key "theatre_skip_votes", "users"
add_foreign_key "theatre_watching_users", "theatres"
add_foreign_key "theatre_watching_users", "users"
add_foreign_key "theatres", "posts", column: "current_post_id"
+1 -1
ファイルの表示
@@ -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 -1
ファイルの表示
@@ -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
-6
ファイルの表示
@@ -1,6 +0,0 @@
namespace :post do
desc '投稿同期(ニコニコ以外)'
task sync: :environment do
Youtube::Sync.new.sync!
end
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }
trait :banned do
banned_at { Time.current }
end
end
end
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
+3 -12
ファイルの表示
@@ -1,24 +1,15 @@
FactoryBot.define do
factory :user do
name { nil }
name { "test-user" }
inheritance_code { SecureRandom.uuid }
role { 'guest' }
banned_at { nil }
trait :guest do
role { 'guest' }
end
role { "guest" }
trait :member do
role { 'member' }
role { "member" }
end
trait :admin do
role { 'admin' }
end
trait :banned do
banned_at { Time.current }
end
end
end
-51
ファイルの表示
@@ -1,51 +0,0 @@
require 'rails_helper'
RSpec.describe PostImplication, type: :model do
let!(:post_record) do
Post.create!(
title: 'post',
url: 'https://example.com/post-implication-post'
)
end
let!(:parent_post) do
Post.create!(
title: 'parent post',
url: 'https://example.com/post-implication-parent'
)
end
it 'is valid with post and parent_post' do
implication = described_class.new(
post: post_record,
parent_post:
)
expect(implication).to be_valid
end
it 'does not allow same post as parent_post' do
implication = described_class.new(
post: post_record,
parent_post: post_record
)
expect(implication).not_to be_valid
expect(implication.errors[:parent_post_id]).to be_present
end
it 'does not allow duplicate pair' do
described_class.create!(
post: post_record,
parent_post:
)
duplicate = described_class.new(
post: post_record,
parent_post:
)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:post_id]).to be_present
end
end
-29
ファイルの表示
@@ -1,29 +0,0 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
end
end
+1 -1
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
parent: post_record.parent,
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,
-4
ファイルの表示
@@ -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 -74
ファイルの表示
@@ -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) }
@@ -234,7 +161,7 @@ RSpec.describe Tag, type: :model do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
parent: post.parent,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
-47
ファイルの表示
@@ -1,47 +0,0 @@
require 'rails_helper'
RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do
it 'returns a stable payload for bad requests' do
get '/tags/name/%20/deerjikists'
expect(response).to have_http_status(:bad_request)
expect(json).to include(
'type' => 'bad_request',
'message' => be_present,
'errors' => {},
'base_errors' => [be_present])
end
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end
end
describe 'model validation errors' do
it 'returns field messages for model errors' do
user = create(:user)
sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
expect(json.fetch('errors').fetch('name')).to include(be_present)
end
end
end
-76
ファイルの表示
@@ -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
ファイル差分が大きすぎるため省略します 差分を読込み
-33
ファイルの表示
@@ -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
+8 -18
ファイルの表示
@@ -141,21 +141,16 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do
before { sign_in_as(guest_user) }
it 'returns 422 when tag is blank' do
it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
expect(response).to have_http_status(:bad_request)
end
it 'returns 422 when both file and url are blank' do
it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
expect(response).to have_http_status(:bad_request)
end
it 'creates a material with an attached file' do
@@ -266,26 +261,21 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:not_found)
end
it 'returns 422 when tag is blank' do
it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: {
tag: ' ',
file: dummy_upload
}
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
expect(response).to have_http_status(:bad_request)
end
it 'returns 422 when both file and url are blank' do
it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
expect(response).to have_http_status(:bad_request)
end
it 'updates tag, url, file, and updated_by_user' do
+7 -93
ファイルの表示
@@ -3,68 +3,12 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do
create_list(:tag, 3, :nico)
get '/tags/nico', params: { page: 2, limit: 2 }
it 'returns tags and next_cursor when overflowing limit' do
create_list(:tag, 21, :nico)
get '/tags/nico', params: { limit: 20 }
expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1)
expect(json['count']).to eq(3)
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: {
name: 'search_',
linked_tag: 'destination_',
link_status: 'linked'
}
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id])
get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' }
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
expect(json['tags'].size).to eq(20)
expect(json['next_cursor']).to be_present
end
end
@@ -131,7 +75,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it 'returns 422 when linked tag normalises to nico tag' do
it '400 when linked tag normalises to nico tag' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
@@ -143,37 +87,7 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
sign_in_as(member)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns tag name validation errors on the tags field and rolls back created tags' do
sign_in_as(member)
TagNameSanitisationRule.create!(
priority: 1,
source_pattern: 'invalid',
replacement: 'valid'
)
nico_tag
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
expect(response).to have_http_status(:bad_request)
end
end
end
ファイル差分が大きすぎるため省略します 差分を読込み
+11 -27
ファイルの表示
@@ -21,29 +21,22 @@ RSpec.describe 'TagVersions API', type: :request do
event_type:,
name:,
category:,
deprecated_at: nil,
aliases: [],
parent_tags: [],
created_by_user:,
created_at:
)
version =
TagVersion.create!(
tag: tag,
version_no: version_no,
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,
created_at: created_at)
tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no)
tag.version_no = version_no if tag.respond_to?(:version_no=)
version
TagVersion.create!(
tag: tag,
version_no: version_no,
event_type: event_type,
name: name,
category: category,
aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user,
created_at: created_at
)
end
let!(:v1) do
@@ -67,7 +60,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 +128,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 +173,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' }

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