Compare commits

..

20 Commits

Author SHA1 Message Date
みてるぞ a07cab97f3 Merge remote-tracking branch 'origin/main' into feature/346 2026-05-11 02:42:06 +09:00
みてるぞ bbf14e3067 #346 2026-05-11 02:41:29 +09:00
みてるぞ 2de7e13a8a Codex 用ファイル追加 (#346) (#347)
#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #347
2026-05-11 02:30:55 +09:00
みてるぞ d3f2b009bc #346 2026-05-11 02:30:13 +09:00
みてるぞ e03cc01109 投稿排他 (#171) (#345)
#171

#171

#171

#171

#171

#171

#171

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #345
2026-05-10 11:16:49 +09:00
みてるぞ b47cdc7ad7 BAN の実装 (#327) (#342)
#327

#327

#327

#327

Merge remote-tracking branch 'origin/main' into feature/327

#327

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #342
2026-05-04 16:22:13 +09:00
みてるぞ 52aa1615b6 ニジラー詳細ページ作成 (#63) (#341)
#63

#63

#63

#63

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #341
2026-05-04 03:37:12 +09:00
みてるぞ dceed1caa1 親投稿機能 (#46) (#339)
Merge remote-tracking branch 'origin/main' into feature/046

#46

#46

#46

#46

#46

#46

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #339
2026-05-03 03:21:35 +09:00
みてるぞ 5002859fc8 YouTube の自動同期 (#314) (#340)
#314

#314

#314

#314

#314

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #340
2026-05-02 17:56:14 +09:00
みてるぞ fcd3b87b2a 奪はれた別名の履歴追加 (#329) (#338)
#329

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #338
2026-04-27 12:45:06 +09:00
みてるぞ 0ff7fdf78a Wiki のバージョン管理 (#317) (#333)
#317

#317

#317

#317

#317

#317

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #333
2026-04-26 22:17:25 +09:00
みてるぞ b2c3e02ccc ユーザ作成時に IP アドレス連携するやぅに (#323) (#326)
Merge branch 'main' into feature/323

Merge branch 'main' into feature/323

Merge branch 'main' into feature/323

#323

#323

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #326
2026-04-25 21:00:22 +09:00
みてるぞ c112576b11 ニコニコ DB 逆連携 (#200) (#331)
#200

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #331
2026-04-25 20:46:49 +09:00
みてるぞ 6235b293f0 タグ履歴ページ (#321) (#330)
#321

#321

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #330
2026-04-24 02:21:26 +09:00
みてるぞ 43cd38a216 タグ詳細 (#318) (#328)
#318

#318

#318

#318

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #328
2026-04-23 00:06:49 +09:00
みてるぞ 8ff1819d5a 同期バグ修正 (#324) (#325)
#324

Reviewed-on: #325
2026-04-19 23:04:15 +09:00
みてるぞ bde7d33949 タグ履歴 (#309) (#319)
#309

#309

#309

#309

#309

Merge remote-tracking branch 'origin/main' into feature/309

#309

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #319
2026-04-19 20:21:51 +09:00
みてるぞ 5c7580d571 ニコタグ連携バグ修正 (#294) (#316)
#294

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #316
2026-04-19 16:44:20 +09:00
みてるぞ 48f823a7c8 履歴画面変更(#308) (#315)
Merge branch 'main' into feature/308

#308

#308

#308

#308

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #315
2026-04-18 05:43:33 +09:00
みてるぞ bd11e37fd3 緊急対応(#312) (#313)
'backend/config/storage.yml' を更新

Reviewed-on: #313
2026-04-15 20:21:51 +09:00
123 changed files with 9311 additions and 766 deletions
+43
View File
@@ -0,0 +1,43 @@
---
name: 'Codex task'
about: 'Codex に実装させるための課題'
title: ''
labels:
- codex-ready
---
## 背景
なぜ必要か。
## 対象範囲
- 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 を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+43
View File
@@ -0,0 +1,43 @@
---
name: 'Codex task'
about: 'Codex に実装させるための課題'
title: ''
labels:
- codex-ready
---
## 背景
なぜ必要か。
## 対象範囲
- 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 を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+143
View File
@@ -0,0 +1,143 @@
# 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 preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`.
## 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: do not use `%w` or `%i`.
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
- Do not add production dependencies without explicit approval.
## 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`.
- 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.
- 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.
## 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 the existing frontend verification commands that apply: `npm run build` and `npm run lint`.
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
A task is complete only when:
- implementation is complete,
- relevant verification commands pass, or failures are clearly explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
+147
View File
@@ -0,0 +1,147 @@
# 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.
## 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.
- Do not use `%w` or `%i` in new Ruby code.
- 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.
## 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, 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 and cover conflicts in request specs.
## 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.
## 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.
@@ -1,14 +1,16 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user!
def current_user def current_user = @current_user
@current_user
end
private private
def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end
@@ -22,4 +24,17 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end 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
end end
@@ -30,15 +30,22 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i id = params[:id].to_i
tag = Tag.find(id) tag = Tag.find(id)
return head :bad_request if tag.category != 'nico' return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split(' ') linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false) linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' } with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
tag.save! tag.save!
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end end
end end
@@ -0,0 +1,119 @@
class PostVersionsController < ApplicationController
def index
post_id = params[:post].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
tag_name =
if tag_id
TagName.joins(:tag).find_by(tag: { id: tag_id })
end
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
kw: "% #{ escaped } %")
end
count = q.except(:select, :order, :limit, :offset).count
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
end
private
def serialise_versions rows
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row|
cur_tags = split_tags(row.tags)
prev_tags = split_tags(row.attributes['prev_tags'])
{
post_id: row.post_id,
version_no: row.version_no,
event_type: row.event_type,
title: {
current: row.title,
prev: row.attributes['prev_title']
},
url: {
current: row.url,
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
tags: build_version_tags(cur_tags, prev_tags),
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601
},
original_created_before: {
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601
},
created_at: row.created_at.iso8601,
created_by_user:
if row.created_by_user_id
{
id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id]
}
end
}
end
end
def build_version_tags(cur_tags, prev_tags)
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{
name:,
type:
}
end
end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end
+315 -21
View File
@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: { tag_name: :wiki_page }) .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(tags: { tag_name: :wiki_page }) post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -104,12 +104,12 @@ class PostsController < ApplicationController
end end
def show def show
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags), .merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20)) related: PostRepr.many(post.related(limit: 20)))
end end
def create def create
@@ -123,26 +123,36 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] 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, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) post.thumbnail.attach(thumbnail) if thumbnail.present?
ActiveRecord::Base.transaction do ApplicationRecord.transaction do
post.save! post.save!
tags = Tag.normalise_tags(tag_names)
tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail! post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end end
def viewed def viewed
@@ -163,30 +173,76 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
force = bool?(:force)
merge = bool?(:merge)
return head :bad_request if force && merge
base_version_no = parse_base_version_no
return head :bad_request if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = Post.find(params[:id].to_i) post = nil
conflict_json = nil
ActiveRecord::Base.transaction do ApplicationRecord.transaction do
post.update!(title:, original_created_from:, original_created_before:) post = Post.lock.find(params[:id].to_i)
tags = post.tags.where(category: 'nico').to_a +
Tag.normalise_tags(tag_names, with_tagme: false) base_version = nil
tags = Tag.expand_parent_tags(tags) base_snapshot = nil
sync_post_tags!(post, tags) current_snapshot = nil
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
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:)
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 end
apply_post_snapshot!(post, snapshot_to_apply)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload post.reload
json = post.as_json json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end end
def changes def changes
@@ -204,7 +260,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present? pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present? pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user, pts = pts.includes(:post, :created_user, :deleted_user,
tag: { tag_name: :wiki_page }) tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = [] events = []
pts.each do |pt| pts.each do |pt|
@@ -346,4 +402,242 @@ class PostsController < ApplicationController
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
end 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.nil? || id <= 0
id
}.uniq
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add(:base, '自分自身を親投稿にはできません.')
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(:base, "存在しない親投稿 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.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
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
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
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])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
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
end end
@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children << Tag.find(child_id) rescue nil parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
TagImplication.find_or_create_by!(parent_tag: parent, tag: child)
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end
head :no_content head :no_content
end end
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
TagImplication.find_by(parent_tag: parent, tag: child)&.destroy!
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end
head :no_content head :no_content
end end
@@ -0,0 +1,92 @@
class TagVersionsController < ApplicationController
def index
tag_id = params[:id].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'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
count = q.except(:select, :order, :limit, :offset).count
versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_versions.id DESC'))
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
end
private
def serialise_versions rows
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row|
cur_aliases = split_values(row.aliases)
prev_aliases = split_values(row.attributes['prev_aliases'])
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
tags_by_id =
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.where(id: all_parent_tag_ids)
.index_by(&:id)
parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
type: h[:type] }
end
{ tag_id: row.tag_id,
version_no: row.version_no,
event_type: row.event_type,
name: { current: row.name, prev: row.attributes['prev_name'] },
category: { current: row.category, prev: row.attributes['prev_category'] },
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
parent_tags:,
created_at: row.created_at.iso8601,
created_by_user: row.created_by_user_id &&
{ id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id] } }
end
end
def build_version_values cur_values, prev_values, key:
(cur_values | prev_values).map do |value|
type =
if cur_values.include?(value) && prev_values.include?(value)
'context'
elsif cur_values.include?(value)
'added'
else
'removed'
end
{ key => value, type: }
end
end
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end
+202 -9
View File
@@ -1,3 +1,7 @@
require 'net/http'
require 'uri'
class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -66,7 +70,7 @@ class TagsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
render json: { tags: TagRepr.base(tags), count: q.size } render json: { tags: TagRepr.many(tags), count: q.size }
end end
def with_depth def with_depth
@@ -182,7 +186,8 @@ class TagsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless tag return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists) render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end end
def deerjikists_by_name def deerjikists_by_name
@@ -194,7 +199,31 @@ class TagsController < ApplicationController
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists) 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 do
platform = _1[:platform]
code = normalise_deerjikist_code(platform, _1[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
deerjikist.save!
end
end
render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end
def materials_by_name def materials_by_name
@@ -209,6 +238,60 @@ class TagsController < ApplicationController
render json: build_tag_children(tag) render json: build_tag_children(tag)
end end
def update_all
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
name = params[:name].to_s.strip
category = params[:category].to_s.strip
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 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
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name
name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.update!(category:)
tag.tag_name.update!(name:)
alias_names << old_name if name_changed
alias_names.delete(name)
update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names)
tag.reload
record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end
render json: TagRepr.base(tag.reload)
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
@@ -218,20 +301,37 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if name.present? if tag.nico? || (category.present? && category == 'nico')
tag.tag_name.update!(name:) return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end end
if category.present? ApplicationRecord.transaction do
tag.update!(category:) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name
name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present?
tag.reload
record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag) render json: TagRepr.base(tag.reload)
end end
private private
def build_tag_children(tag) def build_tag_children tag
material = tag.materials.first material = tag.materials.first
file = nil file = nil
content_type = nil content_type = nil
@@ -244,4 +344,97 @@ class TagsController < ApplicationController
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:)) material: material.as_json&.merge(file:, content_type:))
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed
wiki_page ||= tag.tag_name.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end
def update_aliases! tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.canonical&.tag
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
end
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
end
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.update!(canonical: nil)
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
end
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)
old_parent_tags = tag.parents.to_a
TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq,
created_by_user: current_user)
tag.reversed_tag_implications.destroy_all
parent_tags.each do |parent_tag|
next if parent_tag == tag
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
end end
+20 -7
View File
@@ -1,18 +1,22 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
user = User.create!(inheritance_code: SecureRandom.uuid, role: 'guest') user = nil
User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
end
render json: { code: user.inheritance_code, render json: { code: user.inheritance_code,
user: user.slice(:id, :name, :inheritance_code, :role) } user: user.slice(:id, :name, :inheritance_code, :role) },
status: :created
end end
def verify def verify
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin)
user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user
return head :forbidden if user.banned?
UserIp.find_or_create_by!(user:, ip_address:) attach_ip_address!(user)
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end end
@@ -41,9 +45,18 @@ class UsersController < ApplicationController
return head :bad_request if name.blank? return head :bad_request if name.blank?
if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :created render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else else
render json: user.errors, status: :unprocessable_entity render json: user.errors, status: :unprocessable_entity
end end
end end
private
def attach_ip_address! user
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.create_or_find_by!(ip_address: ip_bin)
UserIp.create_or_find_by!(user:, ip_address:)
end
end end
@@ -85,22 +85,24 @@ class WikiPagesController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
name = params[:title]&.strip title = params[:title].to_s.strip
body = params[:body].to_s body = params[:body].to_s
return head :unprocessable_entity if name.blank? || body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name:)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
return head :unprocessable_entity if title.blank? || body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
page =
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: current_user,
message:)
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
else rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
render json: { errors: page.errors.full_messages }, head :unprocessable_entity
status: :unprocessable_entity
end
end end
def update def update
@@ -113,12 +115,21 @@ class WikiPagesController < ApplicationController
return head :unprocessable_entity if title.blank? || body.blank? return head :unprocessable_entity if title.blank? || body.blank?
page = WikiPage.find(params[:id]) page = WikiPage.find(params[:id])
base_revision_id = page.current_revision.id base_revision_id = params[:base_revision_id].presence
if params[:title].present? && params[:title].strip != page.title ApplicationRecord.transaction do
return head :unprocessable_entity page.lock!
old_title = page.title
tag = Tag.find_by(tag_name_id: page.tag_name_id)
if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
end end
page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence message = params[:message].presence
Wiki::Commit.content!(page:, Wiki::Commit.content!(page:,
body:, body:,
@@ -126,6 +137,12 @@ class WikiPagesController < ApplicationController
message:, message:,
base_revision_id:) base_revision_id:)
if tag && title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
end
head :ok head :ok
end end
+6 -2
View File
@@ -1,6 +1,10 @@
class IpAddress < ApplicationRecord class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 } validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }
has_many :users 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 end
+5 -1
View File
@@ -1,7 +1,11 @@
module MyDiscard module MyDiscard
extend ActiveSupport::Concern extend ActiveSupport::Concern
included { include Discard::Model } included do
include Discard::Model
default_scope -> { kept }
end
class_methods do class_methods do
def find_undiscard_or_create_by! attrs, &block def find_undiscard_or_create_by! attrs, &block
+7
View File
@@ -0,0 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
validates :name, presence: true
end
+30 -3
View File
@@ -1,7 +1,6 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -13,8 +12,24 @@ class Post < ApplicationRecord
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
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 has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
before_validation :normalise_url before_validation :normalise_url
validates :url, presence: true, uniqueness: true validates :url, presence: true, uniqueness: true
@@ -22,17 +37,29 @@ class Post < ApplicationRecord
validate :validate_original_created_range validate :validate_original_created_range
validate :url_must_be_http_url 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 = { } def as_json options = { }
super(options).merge({ thumbnail: thumbnail.attached? ? super(options).merge(thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url( Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) : thumbnail, only_path: false) :
nil }) nil)
rescue rescue
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
def related limit: nil def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit
+19
View File
@@ -0,0 +1,19 @@
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
+1 -17
View File
@@ -1,29 +1,13 @@
class PostVersion < ApplicationRecord class PostVersion < ApplicationRecord
before_update do include VersionRecord
raise ActiveRecord::ReadOnlyRecord, '版は更新できません.'
end
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '版は削除できません.'
end
belongs_to :post belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true belongs_to :parent, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true, inclusion: { in: event_types.keys }
validates :url, presence: true validates :url, presence: true
validate :validate_original_created_range validate :validate_original_created_range
scope :chronological, -> { order(:version_no, :id) }
private private
def validate_original_created_range def validate_original_created_range
+35 -25
View File
@@ -8,8 +8,6 @@ class Tag < ApplicationRecord
; ;
end end
default_scope -> { kept }
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -36,9 +34,14 @@ class Tag < ApplicationRecord
has_many :deerjikists, dependent: :delete_all has_many :deerjikists, dependent: :delete_all
has_many :materials has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true validates :tag_name, presence: true
@@ -78,27 +81,18 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id def material_id = materials.first&.id
def self.tagme def has_deerjikists = deerjikists.present?
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end
def self.bot def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
end 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.no_deerjikist def self.normalise_tags! tag_names, with_tagme: true,
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) with_no_deerjikist: true,
end deny_nico: true
def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
end
def self.niconico
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
end
def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -112,7 +106,7 @@ class Tag < ApplicationRecord
end end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) } tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id) tags.uniq(&:id)
end end
@@ -150,21 +144,25 @@ class Tag < ApplicationRecord
retry retry
end end
def self.merge_tags! target_tag, source_tags def self.merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag target_tag => Tag
affected_post_ids = Set.new affected_post_ids = Set.new
Tag.transaction do Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
Array(source_tags).compact.uniq.each do |source_tag| Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag source_tag => Tag
next if source_tag == target_tag next if source_tag == target_tag
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.kept.find_each do |source_pt| source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
affected_post_ids << post_id affected_post_ids << post_id
source_pt.discard_by!(nil) source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag) unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
end end
@@ -176,6 +174,7 @@ class Tag < ApplicationRecord
raise ActiveRecord::RecordInvalid.new(source_tag_name) raise ActiveRecord::RecordInvalid.new(source_tag_name)
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard! source_tag.discard!
if source_tag.nico? if source_tag.nico?
@@ -184,10 +183,13 @@ class Tag < ApplicationRecord
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current) updated_at: Time.current)
end end
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end end
Post.where(id: affected_post_ids.to_a).find_each do |post| Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end end
# 投稿件数を再集計 # 投稿件数を再集計
@@ -197,6 +199,14 @@ class Tag < ApplicationRecord
target_tag.reload target_tag.reload
end end
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
private private
def nico_tag_name_must_start_with_nico def nico_tag_name_must_start_with_nico
-2
View File
@@ -1,8 +1,6 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
include MyDiscard include MyDiscard
default_scope -> { kept }
has_one :tag has_one :tag
has_one :wiki_page has_one :wiki_page
+15
View File
@@ -0,0 +1,15 @@
class TagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
enum :category, { deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
general: 'general',
material: 'material',
meta: 'meta' }, validate: true
validates :name, presence: true
validates :category, presence: true
end
+5 -1
View File
@@ -4,7 +4,6 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -19,5 +18,10 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin? 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 end
+19
View File
@@ -0,0 +1,19 @@
module VersionRecord
extend ActiveSupport::Concern
def readonly? = persisted?
included do
belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true
scope :chronological, -> { order(:version_no, :id) }
end
end
+5 -7
View File
@@ -4,8 +4,6 @@ require 'set'
class WikiPage < ApplicationRecord class WikiPage < ApplicationRecord
include MyDiscard include MyDiscard
default_scope -> { kept }
has_many :wiki_revisions, dependent: :destroy has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User' belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User' belongs_to :updated_user, class_name: 'User'
@@ -15,8 +13,13 @@ class WikiPage < ApplicationRecord
foreign_key: :redirect_page_id, foreign_key: :redirect_page_id,
dependent: :nullify dependent: :nullify
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
validates :body, presence: true
def title = tag_name.name def title = tag_name.name
@@ -26,11 +29,6 @@ class WikiPage < ApplicationRecord
def current_revision = wiki_revisions.order(id: :desc).first def current_revision = wiki_revisions.order(id: :desc).first
def body
rev = current_revision
rev.body if rev&.content?
end
def resolve_redirect limit: 10 def resolve_redirect limit: 10
page = self page = self
visited = Set.new visited = Set.new
+8
View File
@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+2 -1
View File
@@ -2,7 +2,8 @@
module PostRepr module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
module_function module_function
+4 -5
View File
@@ -3,15 +3,14 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
module_function module_function
def base tag def base tag
tag.as_json(BASE) tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
parents: tag.parents.map { _1.as_json(BASE) })
end end
def many tags def many(tags) = tags.map { |t| base(t) }
tags.map { |t| base(t) }
end
end end
@@ -0,0 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end
private
def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :tag
def snapshot_attributes
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
end
+16 -42
View File
@@ -1,57 +1,31 @@
class PostVersionRecorder class PostVersionRecorder < VersionRecorder
def self.record! post:, event_type:, created_by_user: def self.record! post:, event_type:, created_by_user:
new(post:, event_type:, created_by_user:).record! new(post:, event_type:, created_by_user:).record!
end end
def initialize post:, event_type:, created_by_user: def initialize post:, event_type:, created_by_user:
@post = post super(record: post, event_type:, created_by_user:)
@event_type = event_type
@created_by_user = created_by_user
end end
def record! def self.ensure_snapshot! post, created_by_user:
@post.with_lock do return if post.post_versions.exists?
latest = @post.post_versions.order(version_no: :desc).first
attrs = snapshot_attributes
return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) record!(post:, event_type: :create, created_by_user:)
PostVersion.create!(
post: @post,
version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
title: attrs[:title],
url: attrs[:url],
thumbnail_base: attrs[:thumbnail_base],
tags: attrs[:tags],
parent: attrs[:parent],
original_created_from: attrs[:original_created_from],
original_created_before: attrs[:original_created_before],
created_at: Time.current,
created_by_user: @created_by_user)
end
end end
private private
def snapshot_attributes def version_class = PostVersion
{ title: @post.title, def version_association = :post_versions
url: @post.url, def record_key = :post
thumbnail_base: @post.thumbnail_base,
tags: @post.snapshot_tag_names.join(' '),
parent: @post.parent,
original_created_from: @post.original_created_from,
original_created_before: @post.original_created_before }
end
def same_snapshot? version, attrs def snapshot_attributes
true && { title: @record.title,
version.title == attrs[:title] && url: @record.url,
version.url == attrs[:url] && thumbnail_base: @record.thumbnail_base,
version.thumbnail_base == attrs[:thumbnail_base] && tags: @record.snapshot_tag_names.join(' '),
version.tags == attrs[:tags] && parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
version.parent_id == attrs[:parent]&.id && original_created_from: @record.original_created_from,
version.original_created_from == attrs[:original_created_from] && original_created_before: @record.original_created_before }
version.original_created_before == attrs[:original_created_before]
end end
end end
@@ -0,0 +1,22 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end
private
def version_class = TagVersion
def version_association = :tag_versions
def record_key = :tag
def snapshot_attributes
{ name: @record.name,
category: @record.category,
aliases: @record.snapshot_aliases.join(' '),
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
end
+38
View File
@@ -0,0 +1,38 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
end
def self.record_tag_snapshot! tag, created_by_user:
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
record!(tag, event_type:, created_by_user:)
end
def self.record_tag_snapshots! tags, created_by_user:
tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:)
end
end
end
+87
View File
@@ -0,0 +1,87 @@
class VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def initialize record:, event_type:, created_by_user:
@record = record
@event_type = event_type.to_s
@created_by_user = created_by_user
validate_event_type!
end
def record!
raise "#{ record_class.name } must be persisted" unless @record.persisted?
ApplicationRecord.transaction do
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version
validate_version_sequence!(latest)
attrs = snapshot_attributes
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
end
end
private
def latest_version = versions.order(version_no: :desc).first
def versions = @record.public_send(version_association)
def base_attributes latest
{ version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
created_at: Time.current,
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 validate_event_type!
return if EVENT_TYPES.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }"
end
def version_class = raise NotImplementedError
def version_association = raise NotImplementedError
def record_key = raise NotImplementedError
def snapshot_attributes = raise NotImplementedError
def record_class = @record.class
end
+56 -37
View File
@@ -7,6 +7,31 @@ module Wiki
; ;
end end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)
if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end
ActiveRecord::Base.transaction do
page.save!
new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)
page
end
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:) new(page:, created_user:).content!(body:, message:, base_revision_id:)
end end
@@ -21,7 +46,12 @@ module Wiki
end end
def content! body:, message:, base_revision_id: def content! body:, message:, base_revision_id:
normalised = normalise_body(body) normalised = self.class.normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end
lines = split_lines(normalised) lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
@@ -37,10 +67,19 @@ module Wiki
current_id = @page.wiki_revisions.maximum(:id) current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i if current_id && current_id != base_revision_id.to_i
raise Conflict, raise Conflict,
"競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })." "競合が発生してゐます" +
"(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
end end
end end
@page.update!(body: normalised)
WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)
rev = WikiRevision.create!( rev = WikiRevision.create!(
wiki_page: @page, wiki_page: @page,
base_revision_id:, base_revision_id:,
@@ -54,65 +93,45 @@ module Wiki
rows = line_ids.each_with_index.map do |line_id, pos| rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end end
WikiRevisionLine.insert_all!(rows) WikiRevisionLine.insert_all!(rows) if rows.any?
rev rev
end end
end end
def redirect! redirect_page:, message:, base_revision_id: def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present? def self.normalise_body body
current_id = @page.wiki_revisions.maximum(:id) s = body.to_s
if current_id && current_id != base_revision_id.to_i s.gsub!(/\r\n?/, "\n")
raise Conflict, s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
"競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })." s.gsub(/\n+$/, '')
end
end
WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :redirect,
redirect_page:,
message:,
lines_count: 0,
tree_sha256: nil)
end
end end
private private
def normalise_body body def split_lines(body) = body.split("\n")
s = body.to_s
s.gsub!("\r\n", "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
end
def split_lines body
body.split("\n")
end
def upsert_lines! lines, line_shas def upsert_lines! lines, line_shas
now = Time.current now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_rows = [] missing_by_sha = { }
line_shas.each_with_index do |sha, i| line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha) next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)
missing_rows << { sha256: sha, missing_by_sha[sha] = {
sha256: sha,
body: lines[i], body: lines[i],
created_at: now, created_at: now,
updated_at: now } updated_at: now }
end end
if missing_rows.any? if missing_by_sha.any?
WikiLine.upsert_all(missing_rows) WikiLine.upsert_all(missing_by_sha.values)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end end
@@ -0,0 +1,21 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end
def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end
private
def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page
def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end
@@ -0,0 +1,73 @@
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
View File
@@ -0,0 +1,168 @@
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
@@ -0,0 +1,32 @@
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
+7 -1
View File
@@ -6,10 +6,11 @@ Rails.application.routes.draw do
delete ':child_id', action: :destroy delete ':child_id', action: :destroy
end end
resources :tags, only: [:index, :show, :update] do resources :tags, only: [:index, :show] do
collection do collection do
get :autocomplete get :autocomplete
get :'with-depth', action: :with_depth get :'with-depth', action: :with_depth
get :versions, to: 'tag_versions#index'
scope :name do scope :name do
get ':name/deerjikists', action: :deerjikists_by_name get ':name/deerjikists', action: :deerjikists_by_name
@@ -19,7 +20,11 @@ Rails.application.routes.draw do
end end
member do member do
put '', action: :update_all
patch '', action: :update
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end
@@ -49,6 +54,7 @@ Rails.application.routes.draw do
collection do collection do
get :random get :random
get :changes get :changes
get :versions, to: 'post_versions#index'
end end
member do member do
+16 -1
View File
@@ -1,12 +1,27 @@
env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin' env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin'
set :path, '/var/www/btrc-hub/backend'
set :environment, 'production'
set :output, standard: '/var/log/btrc_hub_nico_sync.log', set :output, standard: '/var/log/btrc_hub_nico_sync.log',
error: '/var/log/btrc_hub_nico_sync_err.log' error: '/var/log/btrc_hub_nico_sync_err.log'
every 1.day, at: '3:00 pm' do job_type :rake,
'cd :path && set -a && . /etc/btrc-hub/backend.env && set +a && ' \
':environment_variable=:environment bundle exec rake :task --silent :output'
every 1.day, at: '11:00 am' do
rake 'nico:sync', environment: 'production' rake 'nico:sync', environment: 'production'
end end
every 1.day, at: '0:00 am' do every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production' 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 end
@@ -2,15 +2,15 @@ require 'set'
class CreatePostVersions < ActiveRecord::Migration[8.0] class CreatePostVersions < ActiveRecord::Migration[8.0]
class Post < ApplicationRecord class Post < ActiveRecord::Base
self.table_name = 'posts' self.table_name = 'posts'
end end
class PostTag < ApplicationRecord class PostTag < ActiveRecord::Base
self.table_name = 'post_tags' self.table_name = 'post_tags'
end end
class PostVersion < ApplicationRecord class PostVersion < ActiveRecord::Base
self.table_name = 'post_versions' self.table_name = 'post_versions'
end end
@@ -0,0 +1,156 @@
class CreateTagVersions < ActiveRecord::Migration[8.0]
class Tag < ActiveRecord::Base
self.table_name = 'tags'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class TagImplication < ActiveRecord::Base
self.table_name = 'tag_implications'
end
class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class NicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
class NicoTagRelation < ActiveRecord::Base
self.table_name = 'nico_tag_relations'
end
def up
create_table :tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.string :category, null: false
t.text :aliases, null: false
t.text :parent_tag_ids, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'tag_versions_version_no_positive'
end
create_table :nico_tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.text :linked_tags, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'nico_tag_versions_version_no_positive'
end
TagVersion.reset_column_information
say_with_time 'Backfilling tag_versions' do
Tag.where(discarded_at: nil)
.where.not(category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)
tag_implication_rows_by_tag_id =
TagImplication
.where(tag_id: tag_ids)
.pluck(:tag_id, :parent_tag_id)
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end
tag_alias_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id')
.where(tags: { id: tag_ids })
.where(tag_names: { discarded_at: nil })
.pluck('tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
TagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
category: tag.category,
aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '),
parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end
NicoTagVersion.reset_column_information
say_with_time 'Backfilling nico_tag_versions' do
Tag.where(discarded_at: nil, category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)
tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end
nico_tag_relation_rows_by_tag_id =
NicoTagRelation
.joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id')
.joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id')
.joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id')
.where(nico_tags: { id: tag_ids })
.where(linked_tags: { discarded_at: nil })
.where(tag_names: { discarded_at: nil })
.pluck('nico_tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
NicoTagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end
end
def down
drop_table :nico_tag_versions
drop_table :tag_versions
end
end
@@ -0,0 +1,91 @@
class CreateWikiVersions < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end
class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end
class WikiLine < ActiveRecord::Base
self.table_name = 'wiki_lines'
end
class WikiVersion < ActiveRecord::Base
self.table_name = 'wiki_versions'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
def up
add_column :wiki_pages, :body, :text, after: :tag_name_id
create_table :wiki_versions do |t|
t.references :wiki_page, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :title, null: false
t.text :body, null: false
t.text :reason
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }
t.index [:wiki_page_id, :version_no], unique: true
t.check_constraint 'version_no > 0',
name: 'wiki_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'wiki_versions_event_type_valid'
end
WikiPage.reset_column_information
WikiVersion.reset_column_information
say_with_time 'Backfilling wiki_versions' do
WikiPage.find_each do |page|
base_revision_id = nil
version_no = 1
title = TagName.find(page.tag_name_id).name
body = nil
loop do
rev = WikiRevision.where(wiki_page_id: page.id).find_by(base_revision_id:)
break unless rev
body = WikiRevisionLine.where(wiki_revision_id: rev.id).order(:position).map { |wrl|
WikiLine.find(wrl.wiki_line_id).body
}.join("\n")
WikiVersion.create!(
wiki_page_id: page.id,
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
title:,
body:,
reason: rev.message,
created_at: rev.created_at,
created_by_user_id: rev.created_user_id)
version_no += 1
base_revision_id = rev.id
end
if body
page.update!(body:)
else
page.destroy!
end
end
end
change_column_null :wiki_pages, :body, false
end
def down
drop_table :wiki_versions
remove_column :wiki_pages, :body
end
end
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,27 @@
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
+78 -9
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -50,9 +50,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
@@ -104,6 +105,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
end end
create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
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| 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 "post_id", null: false
t.bigint "target_post_id", null: false t.bigint "target_post_id", null: false
@@ -140,13 +165,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.text "tags", null: false t.text "tags", null: false
t.bigint "parent_id" t.text "parent_post_ids", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_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", "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.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" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
@@ -157,15 +181,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.string "title" t.string "title"
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id" t.bigint "uploaded_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["parent_id"], name: "index_posts_on_parent_id" t.integer "version_no", null: false
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_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 ["url"], name: "index_posts_on_url", unique: true
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -216,6 +240,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end end
create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false t.string "category", default: "general", null: false
@@ -223,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -294,9 +337,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false t.string "role", null: false
t.boolean "banned", default: false, null: false t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -322,16 +366,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false 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 ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" 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 ["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.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 end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -364,6 +411,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end end
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
@@ -377,6 +440,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
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"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
@@ -384,9 +451,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts" 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 "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 "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
@@ -394,6 +459,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
@@ -417,4 +484,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "wiki_revisions", "wiki_pages" add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end end
+23
View File
@@ -0,0 +1,23 @@
namespace :nico do
desc 'ニコニコ DB 逆連携'
task export: :environment do
require 'open3'
mysql_user = ENV.fetch('MYSQL_USER')
mysql_pass = ENV.fetch('MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
_1[%r{nicovideo\.jp/watch/([^/?#]+)}, 1]
}.uniq
next if videos.empty?
_, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path)
raise stderr unless status.success?
end
end
+5
View File
@@ -115,6 +115,10 @@ namespace :nico do
datum['tags'].each do |raw| datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }") name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico) tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
desired_nico_tag_based_ids << tag.id desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載 # 新たに記載される外部タグと連携される内部タグを記載
@@ -149,6 +153,7 @@ namespace :nico do
if post_created if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end end
end end
+6
View File
@@ -0,0 +1,6 @@
namespace :post do
desc '投稿同期(ニコニコ以外)'
task sync: :environment do
Youtube::Sync.new.sync!
end
end
+10
View File
@@ -0,0 +1,10 @@
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
+12 -3
View File
@@ -1,15 +1,24 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
name { "test-user" } name { nil }
inheritance_code { SecureRandom.uuid } inheritance_code { SecureRandom.uuid }
role { "guest" } role { 'guest' }
banned_at { nil }
trait :guest do
role { 'guest' }
end
trait :member do trait :member do
role { "member" } role { 'member' }
end end
trait :admin do trait :admin do
role { 'admin' } role { 'admin' }
end end
trait :banned do
banned_at { Time.current }
end
end end
end end
+2
View File
@@ -3,5 +3,7 @@ FactoryBot.define do
title { "TestPage" } title { "TestPage" }
association :created_user, factory: :user association :created_user, factory: :user
association :updated_user, factory: :user association :updated_user, factory: :user
body { ' ' }
end end
end end
@@ -0,0 +1,51 @@
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
+1 -1
View File
@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), tags: post_record.snapshot_tag_names.join(' '),
parent: post_record.parent, parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from, original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
created_at: Time.current, created_at: Time.current,
+7 -5
View File
@@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model do
context 'when the source tag_name has a wiki_page' do context 'when the source tag_name has a wiki_page' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
let!(:wiki_page) do let!(:wiki_page) do
WikiPage.create!( admin = create_admin_user!
Wiki::Commit.create_content!(
tag_name: source_tag_name, tag_name: source_tag_name,
created_user: create_admin_user!, body: 'source wiki body',
updated_user: create_admin_user! created_by_user: admin,
) message: 'init')
end end
it 'rolls back the transaction' do it 'rolls back the transaction' do
@@ -159,7 +161,7 @@ RSpec.describe Tag, type: :model do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
parent: post.parent, parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,
@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'prevents TagVersion destroy' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'nico:changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
end
+55
View File
@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) } let(:nico_tag) { create(:tag, :nico) }
it '401 when not logged in' do it '401 when not logged in' do
@@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
expect {
patch "/tags/nico/#{nico_tag.id}", params: {
tags: " #{linked_a.name}\n#{linked_b.name} "
}
}.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
names = json.map { |t| t['name'] }
expect(names).to match_array(['nico_linked_a', 'nico_linked_b'])
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it '400 when linked tag normalises to nico tag' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:bad_request)
end
end end
end end
File diff suppressed because it is too large Load Diff
+78 -6
View File
@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
end end
end end
context "when Tag.find raises (invalid ids) it still returns 204" do context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) } before { stub_current_user(admin) }
let(:parent_id) { -1 } let(:parent_id) { -1 }
let(:child_id) { -1 } let(:child_id) { -1 }
it "returns 204 (rescue nil)" do it "returns 404" do
do_request do_request
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end end
end end
end end
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end end
it 'records create and update versions for child tag' do
expect {
do_request
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:no_content)
versions = child.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
expect(versions.second.parent_tag_ids).to eq('')
expect(versions.second.created_by_user_id).to eq(admin.id)
end
end end
context "when Tag.find raises (invalid ids) it still returns 204" do context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) } before { stub_current_user(admin) }
let(:parent_id) { -1 } let(:parent_id) { -1 }
let(:child_id) { -1 } let(:child_id) { -1 }
it "returns 204 (rescue nil)" do it "returns 404" do
do_request do_request
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end end
end end
end end
+248
View File
@@ -0,0 +1,248 @@
require 'rails_helper'
RSpec.describe 'TagVersions API', type: :request do
let(:member) { create(:user, :member, name: 'version member') }
let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) }
let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) }
let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) }
let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) }
let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) }
let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) }
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }
def create_tag_version!(
tag:,
version_no:,
event_type:,
name:,
category:,
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,
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
end
let!(:v1) do
create_tag_version!(
tag: tag,
version_no: 1,
event_type: 'create',
name: 'old_tag_name',
category: 'general',
aliases: ['alias_shared', 'alias_old'],
parent_tags: [parent_shared, parent_old],
created_by_user: member,
created_at: t_v1
)
end
let!(:v2) do
create_tag_version!(
tag: tag,
version_no: 2,
event_type: 'update',
name: 'new_tag_name',
category: 'meme',
aliases: ['alias_shared', 'alias_new'],
parent_tags: [parent_shared, parent_new],
created_by_user: member,
created_at: t_v2
)
end
let!(:other_v1) do
create_tag_version!(
tag: other_tag,
version_no: 1,
event_type: 'create',
name: 'other_tag_name',
category: 'general',
aliases: ['other_alias'],
parent_tags: [other_parent],
created_by_user: member,
created_at: t_other
)
end
describe 'GET /tags/versions' do
it 'returns all versions in reverse chronological order when id is omitted' do
get '/tags/versions'
expect(response).to have_http_status(:ok)
expect(json).to include('versions', 'count')
expect(json.fetch('count')).to eq(3)
versions = json.fetch('versions')
expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([
[other_tag.id, 1],
[tag.id, 2],
[tag.id, 1]
])
end
it 'returns versions for the specified tag with diffs' do
get '/tags/versions', params: { id: tag.id }
expect(response).to have_http_status(:ok)
expect(json).to include('versions', 'count')
expect(json.fetch('count')).to eq(2)
versions = json.fetch('versions')
expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id])
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
latest = versions.first
expect(latest).to include(
'tag_id' => tag.id,
'version_no' => 2,
'event_type' => 'update',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)
expect(latest.fetch('name')).to eq(
'current' => 'new_tag_name',
'prev' => 'old_tag_name'
)
expect(latest.fetch('category')).to eq(
'current' => 'meme',
'prev' => 'general'
)
expect(latest.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'context' },
{ 'name' => 'alias_new', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'removed' }
)
expect(latest.fetch('parent_tags')).to include(
a_hash_including(
'type' => 'context',
'tag' => a_hash_including(
'id' => parent_shared.id
)
),
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_new.id
)
),
a_hash_including(
'type' => 'removed',
'tag' => a_hash_including(
'id' => parent_old.id
)
)
)
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)
first = versions.second
expect(first).to include(
'tag_id' => tag.id,
'version_no' => 1,
'event_type' => 'create',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)
expect(first.fetch('name')).to eq(
'current' => 'old_tag_name',
'prev' => nil
)
expect(first.fetch('category')).to eq(
'current' => 'general',
'prev' => nil
)
expect(first.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'added' }
)
expect(first.fetch('parent_tags')).to include(
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_shared.id
)
),
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_old.id
)
)
)
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end
it 'returns empty when the specified tag has no versions' do
fresh_tag = create(:tag, name: 'no_versions_tag', category: :general)
get '/tags/versions', params: { id: fresh_tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
it 'clamps page and limit to at least 1' do
get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
versions = json.fetch('versions')
expect(versions.size).to eq(1)
expect(versions.first['version_no']).to eq(2)
end
it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)
wiki_page =
Wiki::Commit.create_content!(
tag_name: wiki_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
Wiki::Commit.content!(
page: wiki_page,
body: 'after',
created_user: member,
message: 'edit',
base_revision_id: wiki_page.current_revision.id)
get '/tags/versions', params: { id: wiki_tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
end
@@ -0,0 +1,160 @@
require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do
let(:member_user) { create(:user, role: 'member') }
def stub_current_user user
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
end
def create_tag! name:, category: :general
tag_name = TagName.create!(name:)
Tag.create!(tag_name:, category:)
end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body:,
created_by_user: user,
message: 'init')
end
before do
stub_current_user(member_user)
end
describe 'PATCH /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_tag_wiki_after',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'patch_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
expect(tag.name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
end
describe 'PUT /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_wiki_after',
category: 'general',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(version).to have_attributes(
event_type: 'update',
title: 'put_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'put_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_category_only',
category: 'meme',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
expect(tag.name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
it 'does not record wiki_version when only aliases change' do
tag = create_tag!(name: 'put_tag_alias_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_alias_only',
category: 'general',
aliases: 'put_tag_alias_only_alias',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
end
end
end
+222 -12
View File
@@ -8,23 +8,35 @@ RSpec.describe 'Tags deerjikists API', type: :request do
let!(:tag) { create(:tag, category: :deerjikist) } let!(:tag) { create(:tag, category: :deerjikist) }
let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }
before do before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika') tag.tag_name.update!(name: 'deerjika')
end end
describe 'GET /tags/:id/deerjikists' do describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/#{ tag_id }/deerjikists" get "/tags/#{tag_id}/deerjikists"
end end
let(:tag_id) { tag.id } let(:tag_id) { tag.id }
context 'when tag exists and has no deerjikists' do context 'when tag exists and has no deerjikists' do
it 'returns 200 and empty array' do it 'returns 200 with tag and empty deerjikists array' do
do_request do_request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([])
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end end
end end
@@ -34,14 +46,24 @@ RSpec.describe 'Tags deerjikists API', type: :request do
Deerjikist.create!(platform: platform2, code: code2, tag: tag) Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end end
it 'returns 200 and deerjikists array' do it 'returns 200 with tag and deerjikists array' do
do_request do_request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to be_a(Array) expect(json).to be_a(Hash)
expect(json.size).to eq(2)
expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly( expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(2)
expect(json['deerjikists'].map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1], [platform1, code1],
[platform2, code2], [platform2, code2],
) )
@@ -53,6 +75,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do it 'returns 404' do
do_request do_request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -60,7 +83,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
describe 'GET /tags/name/:name/deerjikists' do describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/name/#{ name }/deerjikists" get "/tags/name/#{name}/deerjikists"
end end
let(:name) { 'deerjika' } let(:name) { 'deerjika' }
@@ -70,6 +93,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 400' do it 'returns 400' do
do_request do_request
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
end end
@@ -79,23 +103,209 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do it 'returns 404' do
do_request do_request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end
end
context 'when tag exists and has deerjikists' do context 'when tag exists and has deerjikists' do
before do before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag) Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end end
it 'returns 200 and deerjikists array' do it 'returns 200 with tag and deerjikists array' do
do_request do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(1)
expect(json['deerjikists'][0]['platform']).to eq(platform1)
expect(json['deerjikists'][0]['code']).to eq(code1)
end
end
end
describe 'PUT /tags/:id/deerjikists' do
subject(:do_request) do
put "/tags/#{tag_id}/deerjikists", params: payload, as: :json
end
let(:tag_id) { tag.id }
let(:payload) do
[
{ platform: platform1, code: code1 },
{ platform: platform2, code: code2 },
]
end
context 'when not logged in' do
it 'returns 401' do
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
before do
sign_in_as guest
end
it 'returns 403' do
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }
before do
sign_in_as member
end
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when logged in as member' do
before do
sign_in_as member
end
context 'when tag has no deerjikists' do
it 'creates deerjikists and returns deerjikists array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(2)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when tag already has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: 'old-code-1', tag: tag)
Deerjikist.create!(platform: platform2, code: 'old-code-2', tag: tag)
end
it 'replaces deerjikists and returns deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.exists?(platform: platform1, code: 'old-code-1')).to eq(false)
expect(Deerjikist.exists?(platform: platform2, code: 'old-code-2')).to eq(false)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when payload is empty array' do
let(:payload) { [] }
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end
it 'clears deerjikists and returns empty array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(2).to(0)
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
[
{ platform: 'youtube', code: '@deerjika' },
]
end
before do
allow(Net::HTTP).to receive(:get).and_return(
%(<link rel="canonical" href="https://www.youtube.com/channel/#{channel_id}">),
)
end
it 'normalises youtube handle to channel id' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(1)
expect(response).to have_http_status(:ok)
expect(Net::HTTP).to have_received(:get)
expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag))
.to eq(true)
expect(json).to be_a(Array) expect(json).to be_a(Array)
expect(json.size).to eq(1) expect(json.size).to eq(1)
expect(json[0]['platform']).to eq(platform1) expect(json[0]['platform']).to eq('youtube')
expect(json[0]['code']).to eq(code1) expect(json[0]['code']).to eq(channel_id)
end
end end
end end
end end
+550 -6
View File
@@ -1,7 +1,6 @@
require 'cgi' require 'cgi'
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tags API', type: :request do RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tn) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: :general) } let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
@@ -197,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do
expect(response_tags.size).to eq(1) expect(response_tags.size).to eq(1)
expect(response_names).to eq(['norm_a']) expect(response_names).to eq(['norm_a'])
end end
it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'index_parent_tag'),
category: :meme
)
TagImplication.create!(tag:, parent_tag:)
get '/tags', params: { name: 'spec_tag' }
expect(response).to have_http_status(:ok)
row = response_tags.find { |t| t['name'] == 'spec_tag' }
expect(row['aliases']).to include('unko')
expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag')
parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'index_parent_tag',
'category' => 'meme'
)
end
end end
describe 'GET /tags/:id' do describe 'GET /tags/:id' do
@@ -220,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to have_key('created_at') expect(json).to have_key('created_at')
expect(json).to have_key('updated_at') expect(json).to have_key('updated_at')
end end
it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'show_parent_tag'),
category: :character
)
TagImplication.create!(tag:, parent_tag:)
request
expect(response).to have_http_status(:ok)
expect(json['aliases']).to include('unko')
expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag')
parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'show_parent_tag',
'category' => 'character'
)
end
end end
context 'when tag does not exist' do context 'when tag does not exist' do
@@ -359,14 +404,120 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('meta') expect(tag.category).to eq('meta')
end end
it '存在しない id だと RecordNotFound になる(通常は 404' do it '存在しない id なら 404 を返す' do
patch '/tags/999999999', params: { name: 'x' } patch '/tags/999999999', params: { name: 'x' }
expect(response.status).to be_in([404, 500]) expect(response).to have_http_status(:not_found)
end end
it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do it 'nico category への変更は 422 を返す' do
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
expect(response.status).to be_in([422, 500])
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'creates initial and update tag versions when name and category change' do
expect {
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
versions = tag.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')
expect(versions.second.name).to eq('new_tag_name')
expect(versions.second.category).to eq('meme')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end
it 'returns 422 when changing normal tag category to nico' do
expect {
patch "/tags/#{tag.id}", params: { category: 'nico' }
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.category).to eq('general')
end
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end
it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_wiki_renamed_tag',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'tag の category だけを変更しても wiki version は作成しない' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
before_wiki_version_count = wiki_page.reload.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
end end
end end
end end
@@ -510,4 +661,397 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
describe 'PUT /tags/:id' do
context '未ログイン' do
before { stub_current_user(nil) }
it '401 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unauthorized)
end
end
context 'ログインしてゐるが member でない' do
before { stub_current_user(non_member_user) }
it '403 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:forbidden)
end
end
context 'member' do
before { stub_current_user(member_user) }
it '存在しない id なら 404 を返す' do
put '/tags/999999999', params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:not_found)
end
it 'name が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: '',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
end
it 'category が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: '',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'name, category, aliases, parent tags をまとめて更新できる' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_old_parent'),
category: :general
)
kept_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_kept_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)
TagImplication.create!(tag:, parent_tag: kept_parent)
put "/tags/#{ tag.id }", params: {
name: 'put_renamed_tag',
category: 'meme',
aliases: 'put_alias_a put_alias_b put_alias_a',
parent_tags: 'put_kept_parent put_new_parent',
}
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq('put_renamed_tag')
expect(tag.category).to eq('meme')
expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name)
expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name)
old_name_alias = TagName.find_by(name: 'spec_tag')
expect(old_name_alias).to be_present
expect(old_name_alias.canonical).to eq(tag.tag_name)
expect(alias_tn.reload.canonical).to be_nil
expect(tag.parents.map(&:name)).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)
expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist
expect(json['name']).to eq('put_renamed_tag')
expect(json['category']).to eq('meme')
expect(json['aliases']).to contain_exactly(
'put_alias_a',
'put_alias_b',
'spec_tag'
)
expect(json['parents'].map { |t| t['name'] }).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)
end
it 'aliases に現在名を指定しても alias には残さない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'spec_tag put_alias_self_test',
parent_tags: '',
}
expect(response).to have_http_status(:ok)
tag.reload
expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name)
expect(json['aliases']).to include('put_alias_self_test')
expect(json['aliases']).not_to include('spec_tag')
end
it 'parent_tags に自分自身を指定しても自己参照は作らない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: 'spec_tag',
}
expect(response).to have_http_status(:ok)
expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist
expect(tag.reload.parents).to eq([])
end
it 'initial and update tag versions を作成する' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_versioned_tag',
category: 'meta',
aliases: '',
parent_tags: '',
}
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
versions = tag.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')
expect(versions.second.name).to eq('put_versioned_tag')
expect(versions.second.category).to eq('meta')
expect(versions.second.aliases.split).to include('spec_tag')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end
it 'parent tag の snapshot も作成する' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
category: :general
)
new_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_new_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: new_parent.name,
}
expect(response).to have_http_status(:ok)
expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create')
expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create')
end
it 'normal tag を nico category には変更できない' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)
expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end
it 'system tag の name は変更できない' do
system_tag = Tag.tagme
old_name = system_tag.name
old_category = system_tag.category
expect {
put "/tags/#{ system_tag.id }", params: {
name: 'put_system_tag_renamed',
category: old_category,
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(system_tag.reload.name).to eq(old_name)
expect(system_tag.category).to eq(old_category)
end
it 'wiki を持つ tag を更新すると wiki version も作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
Wiki::Commit.content!(
page: wiki_page,
body: 'wiki body before',
created_user: member_user,
message: 'init'
)
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_version_tag',
category: 'meme',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_version_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_old_owner'),
category: :general
)
stolen_alias = TagName.create!(
name: 'put_stolen_alias',
canonical: old_owner.tag_name
)
expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias')
expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko put_stolen_alias',
parent_tags: '',
}
}
.to change { tag.reload.tag_versions.count }.by(2)
.and change { old_owner.reload.tag_versions.count }.by(2)
expect(response).to have_http_status(:ok)
expect(stolen_alias.reload.canonical).to eq(tag.tag_name)
expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias')
old_owner_versions = old_owner.tag_versions.order(:version_no)
expect(old_owner_versions.first.event_type).to eq('create')
expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias')
expect(old_owner_versions.second.event_type).to eq('update')
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general
)
TagImplication.create!(tag: child, parent_tag: tag)
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: child.name,
}
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end
it 'tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_renamed_tag',
category: 'general',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
end
end
end end
+218 -62
View File
@@ -1,110 +1,266 @@
require "rails_helper" require 'rails_helper'
RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }
RSpec.describe "Users", type: :request do before do
describe "POST /users" do allow_any_instance_of(ActionDispatch::Request)
it "creates guest user and returns code" do .to receive(:remote_ip)
post "/users" .and_return(remote_ip)
expect(response).to have_http_status(:ok) end
expect(json["code"]).to be_present
expect(json["user"]["role"]).to eq("guest") def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)
expect(response).to have_http_status(:created)
expect(json['code']).to be_present
expect(json['user']['role']).to eq('guest')
user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect {
post '/users'
}.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end end
end end
describe "POST /users/code/renew" do describe 'POST /users/code/renew' do
it "returns 401 when not logged in" do it 'returns 401 when not logged in' do
sign_out post '/users/code/renew'
post "/users/code/renew"
expect(response).to have_http_status(:unauthorized)
end
end
describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it "returns 400 when name is blank" do it 'returns 403 when current user is banned' do
sign_in_as(user) user = create(:user, :banned)
put "/users/#{user.id}", params: { name: " " }
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when current IP address is banned' do
user = create(:user)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
end
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }
it 'returns 401 when current_user id mismatch' do
other_user = create(:user)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 400 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
it "updates name and returns 201 with user slice" do it 'updates name and returns user slice' do
sign_in_as(user) put "/users/#{user.id}",
put "/users/#{user.id}", params: { name: "new-name" } params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id) expect(json['id']).to eq(user.id)
expect(json["name"]).to eq("new-name") expect(json['name']).to eq('new-name')
user.reload user.reload
expect(user.name).to eq("new-name") expect(user.name).to eq('new-name')
end
it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end
it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end end
end end
describe "POST /users/verify" do describe 'POST /users/verify' do
it "returns valid:false when code not found" do it 'returns valid:false when code not found' do
post "/users/verify", params: { code: "nope" } post '/users/verify', params: { code: 'nope' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(false) expect(json['valid']).to eq(false)
end end
it "creates IpAddress and UserIp, and returns valid:true with user slice" do it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
# request.remote_ip を固定 IpAddress.create!(
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10") ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect { expect {
post "/users/verify", params: { code: user.inheritance_code } post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1) }.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true) expect(json['valid']).to eq(true)
expect(json["user"]["id"]).to eq(user.id) expect(json['user']['id']).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code) expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest") expect(json['user']['role']).to eq('guest')
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる) ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(IpAddress.count).to be >= 1 expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end end
it "is idempotent for same user+ip (does not create duplicate UserIp)" do it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
post "/users/verify", params: { code: user.inheritance_code } post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect { expect {
post "/users/verify", params: { code: user.inheritance_code } post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count) }.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true) expect(json['valid']).to eq(true)
end
it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end end
end end
describe "GET /users/me" do describe 'GET /users/me' do
it "returns 404 when code not found" do it 'returns 404 when code not found' do
get "/users/me", params: { code: "nope" } get '/users/me', params: { code: 'nope' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it "returns user slice when found" do it 'returns user slice when found' do
user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest") user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
get "/users/me", params: { code: user.inheritance_code }
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id) expect(json['id']).to eq(user.id)
expect(json["name"]).to eq("me") expect(json['name']).to eq('me')
expect(json["inheritance_code"]).to eq(user.inheritance_code) expect(json['inheritance_code']).to eq(user.inheritance_code)
expect(json["role"]).to eq("guest") expect(json['role']).to eq('guest')
end
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:forbidden)
end end
end end
end end
@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'returns 409 when base_revision_id is stale' do
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_conflict_request'),
body: 'first',
created_by_user: user,
message: 'init')
stale_id = page.current_revision.id
Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
put "/wiki/#{ page.id }",
params: {
title: 'wiki_conflict_request',
body: 'third',
message: 'stale',
base_revision_id: stale_id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:conflict)
page.reload
expect(page.body).to eq('second')
expect(page.current_revision.message).to eq('other edit')
end
end
@@ -0,0 +1,196 @@
require 'cgi'
require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message:)
end
describe 'POST /wiki' do
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
expect {
post '/wiki',
params: {
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user)
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc")
expect(revision).to be_content
expect(revision.message).to eq('initial commit')
expect(revision.lines_count).to eq(3)
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end
it 'returns 422 and creates nothing when normalised body is blank' do
expect {
post '/wiki',
params: {
title: 'wiki_history_blank_body',
body: "\r\n\r\n",
message: 'blank',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist
end
it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first')
expect {
post '/wiki',
params: {
title: 'wiki_history_duplicate_title',
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1)
end
end
describe 'PUT /wiki/:id' do
it 'updates body and records wiki_revision and wiki_version' do
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_update_body',
body: 'after',
message: 'edit body',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_update_body')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_update_body',
body: 'after',
reason: 'edit body',
created_by_user_id: user.id
)
end
it 'renames title and records wiki_version with new title' do
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_rename_after',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_rename_after')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_rename_after',
body: 'after',
reason: 'rename',
created_by_user_id: user.id
)
end
it 'does not change title, body, revision, or version on stale base_revision_id' do
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
stale_id = page.current_revision.id
Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
page.reload
current_title = page.title
current_body = page.body
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_conflict_renamed',
body: 'third',
message: 'stale edit',
base_revision_id: stale_id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:conflict)
page.reload
expect(page.title).to eq(current_title)
expect(page.body).to eq(current_body)
expect(page.wiki_revisions.count).to eq(revision_count)
expect(page.wiki_versions.count).to eq(version_count)
end
end
end
@@ -0,0 +1,37 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+208 -72
View File
@@ -4,13 +4,19 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
let!(:page) do let!(:page) do
WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p| Wiki::Commit.create_content!(
Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init') tag_name: tn,
end body: 'init',
created_by_user: user,
message: 'init')
end end
describe 'GET /wiki' do describe 'GET /wiki' do
@@ -37,6 +43,7 @@ RSpec.describe 'Wiki API', type: :request do
context 'when wiki page exists' do context 'when wiki page exists' do
it 'returns wiki page with title' do it 'returns wiki page with title' do
request request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to include( expect(json).to include(
@@ -50,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns 404' do it 'returns 404' do
request request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -99,25 +107,34 @@ RSpec.describe 'Wiki API', type: :request do
end end
.to change(WikiPage, :count).by(1) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
page_id = json.fetch('id') page_id = json.fetch('id')
expect(json.fetch('title')).to eq('TestPage') expect(json.fetch('title')).to eq('TestPage')
page = WikiPage.find(page_id) created_page = WikiPage.find(page_id)
rev = page.current_revision version = created_page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'TestPage',
body: "a\nb\nc",
created_by_user_id: member.id
)
rev = created_page.current_revision
expect(rev).to be_present expect(rev).to be_present
expect(rev).to be_content expect(rev).to be_content
expect(rev.message).to eq('init') expect(rev.message).to eq('init')
# body が復元できること expect(created_page.body).to eq("a\nb\nc")
expect(page.body).to eq("a\nb\nc")
# 行数とリレーションの整合
expect(rev.lines_count).to eq(3) expect(rev.lines_count).to eq(3)
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
end end
it 'reuses existing WikiLine rows by sha256' do it 'reuses existing WikiLine rows by sha256' do
@@ -135,6 +152,41 @@ RSpec.describe 'Wiki API', type: :request do
# "a" の WikiLine が増殖しない(1行のはず) # "a" の WikiLine が増殖しない(1行のはず)
expect(WikiLine.where(body: 'a').count).to eq(1) expect(WikiLine.where(body: 'a').count).to eq(1)
end end
it 'deduplicates duplicated new lines before upsert' do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
post endpoint,
params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(rev.wiki_revision_lines.count).to eq(2)
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
it 'normalises CRLF and strips trailing newlines' do
post endpoint,
params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(rev.lines_count).to eq(2)
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
end
end end
end end
@@ -146,17 +198,14 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
#let!(:page) { create(:wiki_page, title: 'TestPage') } let!(:test_tag_name) { TagName.create!(name: 'TestPage') }
let!(:page) do
build(:wiki_page, title: 'TestPage').tap do |p|
puts p.errors.full_messages unless p.valid?
p.save!
end
end
before do let!(:page) do
# 初期版を 1 つ作っておく(更新が“2版目”になるように) Wiki::Commit.create_content!(
Wiki::Commit.content!(page: page, body: "a\nb", created_user: member, message: 'init') tag_name: test_tag_name,
body: "a\nb",
created_by_user: member,
message: 'init')
end end
context 'when not logged in' do context 'when not logged in' do
@@ -182,14 +231,6 @@ RSpec.describe 'Wiki API', type: :request do
headers: auth_headers(member) headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns 422 when title mismatched (if you forbid rename here)' do
put "/wiki/#{page.id}",
params: { title: 'OtherTitle', body: 'x' },
headers: auth_headers(member)
# 君の controller 例だと title 変更は 422 にしてた
expect(response).to have_http_status(:unprocessable_entity)
end
end end
context 'when success' do context 'when success' do
@@ -200,7 +241,18 @@ RSpec.describe 'Wiki API', type: :request do
put "/wiki/#{page.id}", put "/wiki/#{page.id}",
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
headers: auth_headers(member) headers: auth_headers(member)
end.to change(WikiRevision, :count).by(1) end
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
version = page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'TestPage',
body: "x\ny",
created_by_user_id: member.id
)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -211,25 +263,60 @@ RSpec.describe 'Wiki API', type: :request do
expect(page.body).to eq("x\ny") expect(page.body).to eq("x\ny")
expect(rev.base_revision_id).to eq(current_id) expect(rev.base_revision_id).to eq(current_id)
end end
it 'wiki body だけを変更しても tag version は作成しない' do
linked_tag_name = TagName.create!(name: 'wiki_body_only_tag')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
TagVersionRecorder.record!(
tag: linked_tag,
event_type: :create,
created_by_user: member)
linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
current_id = linked_page.current_revision.id
before_count = linked_tag.reload.tag_versions.count
expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_body_only_tag',
body: 'after',
message: 'edit',
base_revision_id: current_id,
},
headers: auth_headers(member)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
end
end end
# TODO: コンフリクト未実装のため,実装したらコメント外す. context 'when conflict' do
# context 'when conflict' do it 'returns 409 when base_revision_id mismatches' do
# it 'returns 409 when base_revision_id mismatches' do # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
# # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
# Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') page.reload
# page.reload
# stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
# put "/wiki/#{page.id}", put "/wiki/#{page.id}",
# params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
# headers: auth_headers(member) headers: auth_headers(member)
# expect(response).to have_http_status(:conflict) expect(response).to have_http_status(:conflict)
# json = JSON.parse(response.body) json = JSON.parse(response.body)
# expect(json['error']).to eq('conflict') expect(json['error']).to eq('conflict')
# end end
# end end
context 'when page not found' do context 'when page not found' do
it 'returns 404' do it 'returns 404' do
@@ -261,14 +348,17 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
# 追加で検索ヒット用 Wiki::Commit.create_content!(
TagName.create!(name: 'spec_wiki_title_2') tag_name: TagName.create!(name: 'spec_wiki_title_2'),
WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), body: 'search body 2',
created_user: user, updated_user: user) created_by_user: user,
message: 'init')
TagName.create!(name: 'unrelated_title') Wiki::Commit.create_content!(
WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'), tag_name: TagName.create!(name: 'unrelated_title'),
created_user: user, updated_user: user) body: 'unrelated body',
created_by_user: user,
message: 'init')
end end
it 'returns up to 20 pages filtered by title like' do it 'returns up to 20 pages filtered by title like' do
@@ -278,7 +368,9 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to be_an(Array) expect(json).to be_an(Array)
titles = json.map { |p| p['title'] } titles = json.map { |p| p['title'] }
expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2')
expect(titles).to include('spec_wiki_title')
expect(titles).to include('spec_wiki_title_2')
expect(titles).not_to include('unrelated_title') expect(titles).not_to include('unrelated_title')
end end
@@ -329,7 +421,12 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し # 別ページを作って revision 無し
tn2 = TagName.create!(name: 'spec_no_rev') tn2 = TagName.create!(name: 'spec_no_rev')
p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) # 異常データ: revision 無し WikiPage を直接作る
p2 = WikiPage.create!(
tag_name: tn2,
body: 'init',
created_user: user,
updated_user: user)
get "/wiki/changes?id=#{p2.id}" get "/wiki/changes?id=#{p2.id}"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -398,29 +495,68 @@ RSpec.describe 'Wiki API', type: :request do
expect(json['older_revision_id']).to eq(rev_a.id) expect(json['older_revision_id']).to eq(rev_a.id)
expect(json['newer_revision_id']).to eq(page.current_revision.id) expect(json['newer_revision_id']).to eq(page.current_revision.id)
end end
it 'returns 422 when "to" is redirect revision' do
# redirect revision を作る
tn2 = TagName.create!(name: 'redirect_target')
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir')
redirect_rev = page.current_revision
expect(redirect_rev).to be_redirect
get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}"
expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns 422 when "from" is redirect revision' do describe 'Wiki::Commit.redirect!' do
tn2 = TagName.create!(name: 'redirect_target2') it 'raises because redirect revisions are deprecated' do
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) target_tag_name = TagName.create!(name: 'redirect_deprecated_target')
target =
Wiki::Commit.create_content!(
tag_name: target_tag_name,
body: 'target',
created_by_user: user,
message: 'init')
Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2') expect {
redirect_rev = page.current_revision Wiki::Commit.redirect!(
page: page,
get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}" redirect_page: target,
expect(response).to have_http_status(:unprocessable_entity) created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id
)
}.to raise_error(RuntimeError, '廃止しました.')
end end
end end
it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
message: 'init')
current_id = linked_page.current_revision.id
expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_linked_tag_for_version_renamed',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change { linked_tag.reload.tag_versions.count }.by(2)
expect(response).to have_http_status(:ok)
linked_tag.reload
expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed')
versions = linked_tag.tag_versions.order(:version_no)
expect(versions.first.event_type).to eq('create')
expect(versions.first.name).to eq('wiki_linked_tag_for_version')
expect(versions.second.event_type).to eq('update')
expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed')
end
end end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
def create_wiki_page title:, body:
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
it 'returns 422 when renaming wiki title to existing title' do
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
source_revision_count = source.wiki_revisions.count
source_version_count = source.wiki_versions.count
old_title = source.title
old_body = source.body
put "/wiki/#{ source.id }",
params: {
title: 'wiki_collision_target',
body: 'new body',
message: 'rename collision',
base_revision_id: source.current_revision.id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_entity)
source.reload
expect(source.title).to eq(old_title)
expect(source.body).to eq(old_body)
expect(source.wiki_revisions.count).to eq(source_revision_count)
expect(source.wiki_versions.count).to eq(source_version_count)
end
it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'wiki_collision_create', body: 'already exists')
expect {
post '/wiki',
params: {
title: 'wiki_collision_create',
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe VersionRecorder do
let(:member) { create(:user, :member) }
let(:post_record) do
Post.create!(
title: 'version recorder post',
url: 'https://example.com/version-recorder-post')
end
it 'updates record version_no when creating the first version' do
version =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect(version.version_no).to eq(1)
expect(post_record.reload.version_no).to eq(1)
end
it 'updates record version_no when creating the next version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated version recorder post')
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version.version_no).to eq(2)
expect(post_record.reload.version_no).to eq(2)
end
it 'does not create a new version or advance version_no when snapshot is unchanged' do
first =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect {
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version).to eq(first)
}.not_to change(PostVersion, :count)
expect(post_record.reload.version_no).to eq(1)
end
it 'raises when record version_no is older than the latest version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated once')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
post_record.update_columns(version_no: 1)
post_record.update!(title: 'updated with stale version_no')
expect {
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
}.to raise_error(RuntimeError, /version_no/)
end
end
@@ -0,0 +1,173 @@
require 'digest'
require 'rails_helper'
RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }
def create_page title:, body: 'initial body'
described_class.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
describe '.create_content!' do
it 'creates page, revision, and version with normalised body' do
expect {
described_class.create_content!(
tag_name: TagName.create!(name: 'commit_integrity_create'),
body: "a\r\nb\r\n\r\n",
created_by_user: user,
message: 'init')
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(revision.lines_count).to eq(2)
expect(version.body).to eq("a\nb")
expect(version.reason).to eq('init')
end
it 'rejects body that becomes blank after normalisation' do
tag_name = TagName.create!(name: 'commit_integrity_blank')
expect {
described_class.create_content!(
tag_name:,
body: "\r\n\r\n",
created_by_user: user,
message: 'blank')
}
.to raise_error(ActiveRecord::RecordInvalid)
expect(WikiPage.where(tag_name:)).not_to exist
end
end
describe '.content!' do
it 'updates page body, revision, and version' do
page = create_page(title: 'commit_integrity_update', body: 'before')
current_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_id)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq('after')
expect(version.body).to eq('after')
expect(version.reason).to eq('edit')
end
it 'does not record tag_version on body-only wiki update' do
tag_name = TagName.create!(name: 'commit_integrity_linked_tag')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
TagVersionRecorder.record!(
tag:,
event_type: :create,
created_by_user: user)
before_count = tag.reload.tag_versions.count
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
expect(tag.reload.tag_versions.count).to eq(before_count)
end
it 'raises conflict and leaves page, revision, and version unchanged' do
page = create_page(title: 'commit_integrity_conflict', body: 'first')
stale_id = page.current_revision.id
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: stale_id)
page.reload
before_body = page.body
before_revision_count = page.wiki_revisions.count
before_version_count = page.wiki_versions.count
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'stale',
base_revision_id: stale_id)
}
.to raise_error(Wiki::Commit::Conflict)
page.reload
expect(page.body).to eq(before_body)
expect(page.wiki_revisions.count).to eq(before_revision_count)
expect(page.wiki_versions.count).to eq(before_version_count)
end
it 'deduplicates duplicated missing wiki lines' do
page = create_page(title: 'commit_integrity_dedup', body: 'before')
duplicated = 'commit_integrity_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'dedup',
base_revision_id: page.current_revision.id)
revision = page.reload.current_revision
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(revision.wiki_revision_lines.count).to eq(2)
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id)
}
.to raise_error(RuntimeError, '廃止しました.')
end
end
end
+150
View File
@@ -0,0 +1,150 @@
require 'rails_helper'
RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = TagName.create!(name: title)
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: user,
message: 'init')
end
describe '.content!' do
it 'stores normalised body in wiki_pages and wiki_versions' do
page = create_page(title: 'commit_normalised_page')
described_class.content!(
page:,
body: "a\r\nb\r\n\r\n",
created_user: user,
message: 'init'
)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(page.current_revision.lines_count).to eq(2)
end
it 'deduplicates duplicated missing wiki lines before upsert' do
page = create_page(title: 'commit_duplicate_line_page')
duplicated = 'commit_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'init'
)
page.reload
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(page.current_revision.lines_count).to eq(2)
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
end
it 'raises conflict when base_revision_id is stale' do
page = create_page(title: 'commit_conflict_page')
first = described_class.content!(
page:,
body: 'first',
created_user: user,
message: 'first'
)
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: first.id
)
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'third',
base_revision_id: first.id
)
}.to raise_error(Wiki::Commit::Conflict)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
expect(tag.reload.tag_versions.count).to eq(0)
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_redirect_source')
target = create_page(title: 'commit_redirect_target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect'
)
}.to raise_error(RuntimeError, '廃止しました.')
end
end
end
@@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe WikiVersionRecorder do
let(:user) { create_member_user! }
def create_page title:, body: 'body'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
describe '.record!' do
it 'records title, body, reason, user, and version number' do
page = create_page(title: 'wiki_version_recorder_basic', body: 'body')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'manual reason',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
version_no: 2,
event_type: 'update',
title: 'wiki_version_recorder_basic',
body: 'body',
reason: 'manual reason',
created_by_user_id: user.id
)
end
it 'does not create duplicated update version for identical snapshot' do
page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body')
described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)
before_count = page.reload.wiki_versions.count
described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)
expect(page.reload.wiki_versions.count).to eq(before_count)
end
it 'creates update version when title changes' do
page = create_page(title: 'wiki_version_recorder_title_before', body: 'body')
page.tag_name.update!(name: 'wiki_version_recorder_title_after')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'rename',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version.title).to eq('wiki_version_recorder_title_after')
expect(version.body).to eq('body')
expect(version.reason).to eq('rename')
end
it 'creates update version when body changes' do
page = create_page(title: 'wiki_version_recorder_body', body: 'before')
page.update!(body: 'after')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'body',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version.title).to eq('wiki_version_recorder_body')
expect(version.body).to eq('after')
expect(version.reason).to eq('body')
end
end
end
@@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe Youtube::ApiClient do
let(:api_key) { 'test-api-key' }
let(:client) { described_class.new(api_key:) }
describe '#search_videos' do
it 'calls YouTube search API with expected params' do
published_after = Time.zone.parse('2026-05-01 00:00:00')
published_before = Time.zone.parse('2026-05-02 00:00:00')
expect(client).to receive(:get_json).with(
'/search',
{
part: 'snippet',
type: 'video',
q: 'ぼざろクリーチャー',
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after.iso8601,
publishedBefore: published_before.iso8601,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.search_videos(
q: 'ぼざろクリーチャー',
published_after:,
published_before:,
page_token: 'NEXT'
)
end
it 'omits nil optional params' do
expect(client).to receive(:get_json).with(
'/search',
hash_excluding(:publishedAfter, :publishedBefore, :pageToken)
).and_return({ 'items' => [] })
client.search_videos(q: 'ぼざろクリーチャー')
end
end
describe '#videos' do
it 'returns empty items when ids are empty' do
expect(client).not_to receive(:get_json)
expect(client.videos([])).to eq({ 'items' => [] })
end
it 'calls videos API with comma separated ids' do
expect(client).to receive(:get_json).with(
'/videos',
{
part: 'snippet,status,contentDetails',
id: 'video-1,video-2'
}
).and_return({ 'items' => [] })
client.videos(['video-1', 'video-2'])
end
end
describe '#playlist_items' do
it 'calls playlistItems API with page token' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT')
end
it 'omits page token when nil' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123')
end
end
describe '#channel' do
it 'calls channels API by id' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
id: 'UC123'
}
).and_return({ 'items' => [] })
client.channel(id: 'UC123')
end
it 'calls channels API by handle' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
forHandle: '@some_handle'
}
).and_return({ 'items' => [] })
client.channel(handle: '@some_handle')
end
it 'raises when neither id nor handle is given' do
expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required')
end
it 'raises when both id and handle are given' do
expect do
client.channel(id: 'UC123', handle: '@some_handle')
end.to raise_error(ArgumentError, 'id or handle is required')
end
end
end
+310
View File
@@ -0,0 +1,310 @@
require 'rails_helper'
RSpec.describe Youtube::Sync do
let(:client) { instance_double(Youtube::ApiClient) }
let(:sync) { described_class.new(client:) }
before do
allow(PostVersionRecorder).to receive(:record!)
allow(PostVersionRecorder).to receive(:ensure_snapshot!)
allow(sync).to receive(:attach_thumbnail_if_needed!)
end
describe '#sync!' do
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return([])
expect(client).not_to receive(:videos)
sync.sync!
end
it 'discovers ids from search and all playlist pages' do
allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー'])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00'))
allow(client).to receive(:search_videos).with(
q: 'ぼざろクリーチャー',
published_after: Time.zone.parse('2026-05-01 00:00:00')
).and_return({
'items' => [
{
'id' => {
'videoId' => 'search-video-1'
}
}
]
})
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'playlist-video-1'
}
}
],
'nextPageToken' => 'NEXT'
})
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: 'NEXT'
).and_return({
'items' => [
{
'snippet' => {
'resourceId' => {
'videoId' => 'playlist-video-2'
}
}
}
]
})
expect(client).to receive(:videos).with(
satisfy do |ids|
ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1']
end
).and_return({ 'items' => [] })
sync.sync!
end
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_NO_MAPPING'
)
]
})
expect do
sync.sync!
end.to change(Post, :count).by(1)
post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)
expect(post.title).to eq('YouTube テスト動画')
expect(post.uploaded_user_id).to be_nil
expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00'))
expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00'))
expect(tag_ids).to include(Tag.tagme.id)
expect(tag_ids).to include(Tag.bot.id)
expect(tag_ids).to include(Tag.youtube.id)
expect(tag_ids).to include(Tag.video.id)
expect(tag_ids).to include(Tag.no_deerjikist.id)
expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :create,
created_by_user: nil
)
end
it 'uses deerjikist tag when channel id is mapped' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist
deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED',
tag: deerjikist_tag
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_MAPPED'
)
]
})
sync.sync!
post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
end
it 'removes no_deerjikist when deerjikist mapping is added later' do
Tag.no_deerjikist
post = Post.create!(
title: '旧タイトル',
url: 'https://www.youtube.com/watch?v=video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)
PostTag.create!(post:, tag: Tag.no_deerjikist)
deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED_LATER',
tag: deerjikist_tag
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_MAPPED_LATER'
)
]
})
sync.sync!
post.reload
tag_ids = post.tags.pluck(:id)
expect(post.title).to eq('新タイトル')
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
)
expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :update,
created_by_user: nil
)
end
it 'matches existing youtu.be URL and does not create duplicate post' do
post = Post.create!(
title: '旧タイトル',
url: 'https://youtu.be/video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_NO_MAPPING'
)
]
})
expect do
sync.sync!
end.not_to change(Post, :count)
expect(post.reload.title).to eq('新タイトル')
end
end
def youtube_video_item(id:, title:, channel_id:)
{
'id' => id,
'snippet' => {
'title' => title,
'channelId' => channel_id,
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'high' => {
'url' => "https://img.youtube.com/#{id}.jpg"
}
},
'tags' => ['tag-a', 'tag-b']
}
}
end
end
@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Youtube::VideoItem do
describe '#initialize' do
it 'extracts fields from YouTube video API item' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'tags' => ['tag-a', 'tag-b'],
'thumbnails' => {
'high' => {
'url' => 'https://img.youtube.com/high.jpg'
},
'medium' => {
'url' => 'https://img.youtube.com/medium.jpg'
}
}
}
}
video = described_class.new(item)
expect(video.id).to eq('video-1')
expect(video.title).to eq('テスト動画')
expect(video.channel_id).to eq('UC123')
expect(video.published_at).to eq(Time.iso8601('2026-05-01T12:34:56Z'))
expect(video.thumbnail_url).to eq('https://img.youtube.com/high.jpg')
expect(video.raw_tags).to eq(['tag-a', 'tag-b'])
expect(video.url).to eq('https://www.youtube.com/watch?v=video-1')
end
it 'uses highest priority thumbnail' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'default' => {
'url' => 'https://img.youtube.com/default.jpg'
},
'standard' => {
'url' => 'https://img.youtube.com/standard.jpg'
},
'maxres' => {
'url' => 'https://img.youtube.com/maxres.jpg'
}
}
}
}
video = described_class.new(item)
expect(video.thumbnail_url).to eq('https://img.youtube.com/maxres.jpg')
end
it 'falls back to empty raw tags' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}
video = described_class.new(item)
expect(video.raw_tags).to eq([])
end
it 'returns nil thumbnail when no thumbnail exists' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}
video = described_class.new(item)
expect(video.thumbnail_url).to be_nil
end
end
end
+2 -4
View File
@@ -2,14 +2,12 @@ module TestRecords
def create_member_user! def create_member_user!
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member', role: 'member')
banned: false)
end end
def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin', role: 'admin')
banned: false)
end end
end end
+100
View File
@@ -0,0 +1,100 @@
require 'rails_helper'
require 'rake'
require 'open3'
RSpec.describe 'nico:export' do
let(:task) { Rake::Task['nico:export'] }
let(:success_status) { instance_double(Process::Status, success?: true) }
let(:failure_status) { instance_double(Process::Status, success?: false) }
def create_post(url)
Post.create!(url:)
end
before(:all) do
Rails.application.load_tasks unless Rake::Task.task_defined?('nico:export')
end
before do
task.reenable
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user')
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass')
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
end
describe 'export' do
it 'exports nicovideo ids to shared nico DB' do
create_post('https://www.nicovideo.jp/watch/sm12345?ref=foo')
create_post('https://www.nicovideo.jp/watch/so67890#comments')
create_post('https://www.nicovideo.jp/watch/nm24680')
create_post('https://example.com/watch/sm99999')
expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
expect(env).to eq(
{
'MYSQL_USER' => 'mysql-user',
'MYSQL_PASS' => 'mysql-pass',
},
)
expect(args.take(3)).to eq(
[
'python3',
'-m',
'tracked_videos.put_bulk_upsert',
],
)
expect(args.drop(3)).to contain_exactly(
'sm12345',
'so67890',
'nm24680',
)
expect(kwargs).to eq(chdir: '/srv/nizika-nico')
['', '', success_status]
end
task.invoke
end
it 'deduplicates video ids' do
create_post('https://www.nicovideo.jp/watch/sm12345')
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
expect(args.drop(3)).to eq(['sm12345'])
['', '', success_status]
end
task.invoke
end
it 'does not call python when there are no nicovideo posts' do
create_post('https://example.com/watch/sm12345')
expect(Open3).not_to receive(:capture3)
task.invoke
end
it 'raises stderr when python command fails' do
create_post('https://www.nicovideo.jp/watch/sm12345')
allow(Open3).to receive(:capture3).and_return(
[
'',
'bulk upsert failed',
failure_status,
],
)
expect {
task.invoke
}.to raise_error(RuntimeError, 'bulk upsert failed')
end
end
end
+105 -1
View File
@@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
parent: post.parent, parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,
@@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post.reload)) expect(version.tags).to eq(snapshot_tags(post.reload))
end end
it '新規 nico tag に nico tag version を作る' do
Tag.bot
Tag.tagme
Tag.niconico
Tag.video
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1)
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('nico:AAA')
expect(version.created_by_user).to be_nil
end
it '既存 post に version が無い場合は create snapshot を補う' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
kept_general = create_tag!('spec_kept_without_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)
Tag.bot
Tag.tagme
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)
versions = post.reload.post_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create'])
expect(versions.first.title).to eq('changed title')
expect(versions.first.tags).to eq(snapshot_tags(post.reload))
end
it '既存 version がある post には update version を作る' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
kept_general = create_tag!('spec_kept_with_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)
PostVersionRecorder.record!(
post: post,
event_type: :create,
created_by_user: nil
)
Tag.bot
Tag.tagme
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)
versions = post.reload.post_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.title).to eq('old')
expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end
end end
+25
View File
@@ -0,0 +1,25 @@
require 'rails_helper'
require 'rake'
RSpec.describe 'post:sync' do
around do |example|
original_application = Rake.application
Rake.application = Rake::Application.new
Rake::Task.define_task(:environment)
load Rails.root.join('lib/tasks/sync_posts.rake')
example.run
ensure
Rake.application = original_application
end
it 'runs Youtube::Sync' do
sync = instance_double(Youtube::Sync)
expect(Youtube::Sync).to receive(:new).once.and_return(sync)
expect(sync).to receive(:sync!).once
Rake::Task['post:sync'].invoke
end
end
+646
View File
@@ -0,0 +1,646 @@
# Codex handoff for BTRC Hub / タグ広場
This document transfers project-specific context from prior ChatGPT-assisted design and review work to Codex.
Use this file as project background.
Use `AGENTS.md`, `backend/AGENTS.md`, and `frontend/AGENTS.md` for concrete coding rules and verification commands.
## Project identity
BTRC Hub / タグ広場 is a collaborative knowledge base for collecting, tagging, explaining, and rediscovering Bocchi the Rock creature-related works.
It is not a generic SNS.
It is not a comment board.
It is not a service for rehosting external content.
It is primarily a structured link, tag, wiki, material, and viewing-party system.
Core domains:
1. Posts
2. Tags
3. Wiki pages
4. Materials
5. Theatre / watch-party features
The project is already publicly accessible and indexed by search engines, but it has not been broadly announced. Treat it as a small public production system, not a private prototype.
## Current stack
Backend:
- Ruby 3.2.2
- Rails 8.0.2 API
- MySQL 8
- Active Storage
- Cloudflare R2 / S3-compatible storage is expected for uploaded files
- RSpec
Frontend:
- React 19.1
- Vite 6.3
- TypeScript 5.8
- Axios
- TanStack Query
- Tailwind CSS
- Framer Motion
- shadcn-like local components
- react-markdown
- react-markdown-editor-lite
- remark-wiki-autolink
Batch / background-like tasks:
- Rake tasks
- Nico sync
- YouTube sync
- Similarity calculation tasks
## Repository working principle
Before editing, inspect the existing implementation.
Do not invent a new architecture when the current repo already has an established convention.
Keep changes scoped to the requested issue.
Prefer small, reviewable changes over broad rewrites.
Do not perform unrelated cleanup in the same patch.
When a task has design ambiguity, first produce a short investigation and recommended plan. Do not silently choose a risky design.
## User coding preferences
General:
- Prefer single quotes for strings unless interpolation, escaping, or framework convention makes double quotes better.
- Do not add production dependencies without explicit approval.
- Do not perform broad formatting churn.
- Do not convert unrelated files to a different style.
Ruby:
- Do not put a space before method-call parentheses.
- Do not use `%w`.
- Do not use `%i`.
- Keep Rails code idiomatic, but preserve the user's style where the repo already uses it.
TypeScript / Python:
- The user prefers GNU-style spacing before parentheses where syntactically valid.
- Preserve existing project formatting if a formatter or nearby code dictates otherwise.
## Current authentication model
The system does not use normal email/password authentication.
Users are authenticated by inheritance code.
Frontend:
- Stores the code in `localStorage.user_code`.
- Sends it as the `X-Transfer-Code` header.
Backend:
- Looks up `users.inheritance_code`.
- Sets `current_user`.
Roles:
- `guest`
- `member`
- `admin`
Important helper:
- `User#gte_member?` returns true for `member` and `admin`.
Never introduce a conventional login assumption unless the issue explicitly asks for it.
## BAN / abuse-control model
The backend currently enforces BAN at API level.
The relevant before_action order is conceptually:
1. Reject banned IP address.
2. Authenticate user if transfer code exists.
3. Reject banned user.
Entities:
- `users.banned_at`
- `ip_addresses.banned_at`
- `user_ips`
IP addresses are stored as binary values using `IPAddr#hton`.
Do not weaken BAN behavior.
Do not move BAN checks behind optional authentication.
Do not make preview, theatre, verify, user creation, or public-looking endpoints bypass BAN without an explicit design decision.
## Public-operation assumptions
Current practical operation:
- A few editor accounts exist.
- Meaningful editing is mostly done by the owner.
- Read access is already public.
- Search engines have indexed the site.
- Future editor applications are expected through Discord.
- Prospective editors are likely people known in the Bocchi creature community.
This means security and moderation issues matter even if traffic is still small.
## Core domain summary
### Posts
Posts are external URL-based link records.
Important properties:
- `url` is required and unique.
- URLs are normalized.
- Only HTTP / HTTPS are allowed.
- Posts can have thumbnails through Active Storage.
- `uploaded_user_id` may be NULL for synced or bot-created posts.
- `original_created_from` and `original_created_before` represent a time range for original content creation.
- When both original time bounds exist, `from < before` is required.
Parent/child posts:
- Current implementation uses `post_implications`.
- It is many-to-many.
- Do not assume `posts.parent_id`.
- Frontend/API clients must send `parent_post_ids`, even when empty.
- `parent_post_ids` is parsed as a space-separated ID string.
- Self-parenting is invalid.
- Missing parent IDs are invalid.
Versions:
- `post_versions` stores snapshots.
- `version_no` is a per-post sequence.
- Snapshot includes title, URL, thumbnail base, tags, parent post IDs, original time bounds, event type, and actor.
- Optimistic locking for posts is planned / important, but do not assume it is fully implemented unless the code proves it.
### Tags
Tags are central.
There is separation between tag names and tag entities:
- `tag_names`
- `tags`
Categories:
- `deerjikist`
- `meme`
- `character`
- `general`
- `material`
- `nico`
- `meta`
Alias model:
- `tag_names.canonical_id` expresses aliases.
- `canonical_id = NULL` means canonical name.
- `canonical_id != NULL` means alias.
- An alias must not point to another alias.
- A tag name that already has a tag or wiki page generally must not be aliasified.
Tag normalization:
- User-entered tags are normalized through existing backend logic.
- Known aliases are canonicalized.
- Parent tags are expanded recursively.
- `nico:` is normally rejected for manual entry.
- Special tags such as tag-request / bot / unknown-deerjikist / video / niconico / youtube must be protected.
Do not casually change tag normalization, alias resolution, or parent expansion. These affect search, wiki, sync, and historical data.
### Nico tags
Nico tags use the `nico` category and have separate versioning.
Important relation:
- `nico_tag_relations` maps external Nico tags to internal tags.
- `nico_tag_id` must be a Nico category tag.
- `tag_id` must not be Nico category.
Do not allow ordinary manual tag editing to create or corrupt Nico tags.
### Deerjikists
Deerjikists map external platform identities to internal `deerjikist` tags.
Known platforms include:
- `nico`
- `youtube`
YouTube handles may be normalized to `UC...` channel IDs.
Do not treat user-facing handles and canonical channel IDs as interchangeable without checking existing code.
### Wiki
Wiki pages are a major knowledge layer.
Important points:
- Wiki pages are tied to tag-like titles.
- Title handling, aliases, and canonical tag names matter.
- There is line-level storage / revision-oriented behavior in the current implementation.
- There has been design tension between wiki revisions and wiki versions.
- Wiki conflict detection using `base_revision_id` exists on the backend side.
- Frontend support for conflict detection must be verified before assuming it is complete.
Do not redesign Wiki storage casually.
Do not add a second competing history system.
Do not break existing wiki URLs.
### Materials
Materials connect files or reference URLs to `material` or `character` tags.
Important properties:
- A material has a `tag_id`.
- The tag must be `material` or `character`.
- A material requires either `url` or attached `file`.
- Active Storage is involved.
- Upload/security policy matters more than plain link posting.
Important unresolved/risky area:
- Material creation permissions have historically been risky because upload endpoints can be abused.
- Prefer `member` or higher for material creation unless the issue explicitly says otherwise.
### Theatre
Theatre is an experimental watch-party style feature.
Known pieces include:
- Display
- Presence
- Next post
- Comments
- Host-like control
Do not assume theatre has complete CRUD/admin support unless the code proves it.
Theatre may become expensive if next-item selection uses random DB ordering.
## Current high-risk areas
Treat these areas with extra care.
### Security
- Preview API SSRF protection.
- External iframe / embed CSP.
- Markdown link safety.
- BAN / IP BAN bypass.
- Transfer-code leakage.
- Guest write access.
- Upload endpoints.
- Admin-only tag operations.
- System tag mutation.
### Data integrity
- Tag alias canonicalization.
- Tag parent expansion.
- Post parent many-to-many relationships.
- Version tables.
- `version_no` synchronization.
- Schema drift from branch migration contamination.
- Wiki revision/version split.
- Material version recording.
### Frontend correctness
- React Hooks must not be called conditionally.
- Role guards are currently spread across components/pages.
- TanStack Query keys must not collide between ID/name or ID/title variants.
- URL path segments containing tag names or wiki titles must use `encodeURIComponent`.
- API response types may allow `null` users for bot or migration data.
- Tag autocomplete has had duplicated logic and stale state hazards.
### Performance
- Avoid unbounded `limit`.
- Avoid `order('RAND()')` for growing tables.
- Avoid loading full relations just to count.
- Avoid Ruby-side sorting/paging for large histories.
- Tag sidebar client-side aggregation can become expensive.
- Wiki full-text search needs deliberate indexing/design.
## Current priority order
Use this as the default priority unless an issue says otherwise.
### P0: Safety before broad announcement
1. Preview API SSRF hardening.
2. Material creation permission tightening.
3. System tag mutation holes.
4. `GET /users/me` transfer-code leakage through query params.
5. Limit caps for index/history/comment APIs.
6. CSP / iframe sandbox policy.
7. Confirm BAN enforcement remains global.
### P1: Core correctness
1. Post optimistic locking with `version_no`.
2. Wiki edit conflict handling.
3. Wiki history/revision model clarification.
4. Wiki search truthfulness: implement body search or remove false UI.
5. Tag alias/canonical/wiki interaction.
6. Tag URL encoding.
7. TanStack Query key separation.
8. Frontend null-user handling.
9. React Hooks rule fixes.
10. Material version policy.
### P2: Operational/admin usability
1. Admin screens for users, IPs, bans, aliases, and settings.
2. Settings table and user settings usage.
3. Better tag sidebar.
4. Better role guard helpers.
5. Better frontend tests.
6. Better issue triage and closure of already-implemented issues.
### P3: Future features
1. Theatre list/create/edit/admin flow.
2. Muted/hidden tags.
3. Tag category custom colors.
4. Responsive refinements.
5. Watch-party improvements.
6. Broader embed support.
## Known issue triage notes
Some existing issues may already be partially or mostly implemented.
Before implementing an issue, check code first.
Examples:
- Tag search and OR/NOT search may already be mostly implemented.
- BAN enforcement may have been implemented after earlier issue drafts.
- YouTube sync exists and should not be treated as purely planned.
- Parent posts are many-to-many in current schema, even if older issues mention one-to-many.
- Some issues may reflect old schema or old branch state.
When in doubt:
1. Inspect current code.
2. Inspect schema.
3. Inspect routes.
4. Inspect frontend usage.
5. Report whether the issue is implemented, partially implemented, not implemented, or obsolete.
6. Only then edit.
## Verification expectations
Backend changes:
- Run RSpec when possible.
- Add request specs for API behavior changes.
- Add model specs for validation / normalization changes.
- Check migrations and schema consistency.
- Do not silently ignore pending migrations.
Frontend changes:
- Run build.
- Run lint if configured.
- Run tests if configured.
- Add tests for important behavior when the test framework exists.
- If frontend tests are not yet installed, state that clearly.
Full-stack changes:
- Verify both backend and frontend compile/test paths where possible.
- Confirm API response shapes match TypeScript types.
- Confirm authorization behavior on both server and UI.
If commands cannot be run because dependencies are missing, report that explicitly. Do not pretend verification passed.
## Branch / migration caution
The project has previously suffered from schema contamination caused by running migrations from another branch.
Be careful when touching:
- `db/schema.rb`
- migration files
- parent post schema
- banned / banned_at schema
- version_no migrations
- wiki asset schema
Before changing migrations:
1. Inspect current schema.
2. Inspect existing migrations.
3. Confirm whether the intended branch already includes related migrations.
4. Prefer additive migrations for shared branches.
5. Do not edit already-applied production migrations unless explicitly instructed.
## API design principles
Prefer explicit server-side authorization.
Do not rely only on frontend hiding.
Do not return sensitive codes unnecessarily.
Use 403 for authorization failures.
Use 422 for validation failures.
Use 409 for edit conflicts.
Do not expose internal exception messages to users.
Clamp or reject abusive limits consistently.
Keep response shape stable unless the issue explicitly includes a breaking API change.
## Frontend design principles
Use existing route and query-key conventions.
Use TanStack Query `enabled` rather than conditional hook calls.
Do not let role-based early returns change hook order.
Centralize repeated tag autocomplete logic when touching it.
Use `encodeURIComponent` for tag names and wiki titles in URL path segments.
Prefer graceful fallback for nullable actors:
- bot operation
- deleted user
- migration-created data
- external sync
Do not assume all API user fields are non-null.
## Testing priorities to add over time
Frontend tests are especially important because the backend already has more mature RSpec coverage.
Suggested first frontend tests:
1. Tag autocomplete.
2. Post form tag editing.
3. Tag URL encoding.
4. Wiki edit conflict UI.
5. Role guard behavior.
6. Null-user history rendering.
7. Dialog behavior.
8. Top navigation responsive behavior.
Backend test priorities:
1. BAN enforcement across public-looking endpoints.
2. Material permissions.
3. Preview SSRF rejection.
4. System tag protection.
5. Post optimistic locking.
6. Wiki conflict detection.
7. Tag alias/canonical behavior.
8. Limit caps.
9. Parent post parsing.
10. Version recorder behavior.
## What Codex should not do without explicit approval
Do not:
- Replace Rails.
- Replace React.
- Replace TanStack Query.
- Redesign the database.
- Rewrite Wiki storage.
- Remove version tables.
- Change authentication model.
- Change role names.
- Change tag category names.
- Add background job infrastructure.
- Add a new UI framework.
- Add a new test framework if one already exists.
- Add major dependencies.
- Change public URL design.
- Change production storage configuration.
- Remove historical data behavior.
- Simplify BAN/security checks.
- Treat the site as private-only.
## Good first Codex tasks
Start with investigation-only tasks.
Example:
```txt
Inspect the repository and summarize the Rails, React, TypeScript, and test setup.
Do not modify files.
List commands that actually exist in this repository.
List risks Codex should know before editing.
```
Then small safe patches:
```txt
Fix a React Hooks rule violation in one file.
Keep behavior unchanged.
Run the relevant frontend verification commands.
```
```txt
Add encodeURIComponent around one tag-name URL path segment.
Add or update a test if the project has a frontend test setup.
Run build/lint.
```
```txt
Add a request spec for a known authorization rule.
Do not change implementation unless the spec fails for the expected reason.
```
Avoid starting with:
- Wiki history redesign.
- Post versioning redesign.
- Full admin screen suite.
- Broad frontend refactor.
- Database cleanup.
- Authentication rewrite.
## Relationship with ChatGPT
ChatGPT has been used for:
- Design review.
- Risk analysis.
- Prioritization.
- Specification reconstruction.
- Migration/locking discussions.
- Codex migration planning.
Codex should be used mainly for:
- Repository inspection.
- Localized implementation.
- Test addition.
- Running verification commands.
- Producing small reviewable diffs.
For ambiguous architecture, Codex should stop and present options rather than implement a guessed design.
## Current strategic stance
The project should not be rewritten from scratch.
The current Rails + React system is acceptable.
The immediate goal is not elegance.
The immediate goal is safe public operation, data integrity, and maintainable incremental improvement.
Priority is:
1. Prevent abuse/security incidents.
2. Preserve data correctness.
3. Make editing safe for multiple users.
4. Add tests around fragile frontend behavior.
5. Improve admin/operation workflows.
6. Optimize performance after obvious dangerous patterns are removed.
## Final rule
When current code, old specs, issue drafts, and memory disagree, current code wins.
When current code is unsafe, write that explicitly and propose a small safe fix.
When the task is too broad, split it.
When verification cannot be performed, say exactly what was not verified.
+29
View File
@@ -0,0 +1,29 @@
# Commands
## Backend
```sh
cd backend
bundle install
bundle exec rails db:migrate
bundle exec rspec
bundle exec rails routes
```
## Frontend
```sh
cd frontend
npm install
npm run dev
npm run build
npm run lint
npm test
```
### Full verification
```sh
cd backend && bundle exec rspec
cd ../frontend && npm run build && npm run lint
```
+80
View File
@@ -0,0 +1,80 @@
# Issue workflow
## Source of truth
Gitea Issues are the source of truth for tasks, discussions, labels, milestones, and status.
Do not copy the full backlog into git.
Repository documents may define:
- issue templates
- triage rules
- Codex task format
- verification rules
- release checklist
## Labels
Recommended labels:
- `P0`
- `P1`
- `P2`
- `P3`
- `security`
- `data-integrity`
- `backend`
- `frontend`
- `wiki`
- `tags`
- `materials`
- `theatre`
- `codex-ready`
- `needs-design`
- `blocked`
- `good-first-codex-task`
## Codex-ready criteria
An issue can be labeled `codex-ready` only when it has:
- clear background
- target area
- concrete tasks
- acceptance criteria
- verification commands
- explicit non-goals
- no unresolved architecture decision
## Workflow
1. Create or refine the issue in Gitea.
2. Add labels and milestone.
3. If design is unclear, label `needs-design`.
4. Discuss design before implementation.
5. When scoped enough, label `codex-ready`.
6. Give Codex the issue URL or copied issue body.
7. Codex creates a branch.
8. Codex implements a small patch.
9. Codex runs verification commands.
10. Human reviews the diff.
11. Merge.
12. Close the issue from the PR/commit message.
## Commit message
Use issue references when possible:
```txt
fix: prevent preview SSRF
Refs: #123
```
or
```
fix: prevent preview SSRF
Closes: #123
```
depending on whether the change fully resolves the issue.
+8
View File
@@ -0,0 +1,8 @@
# Release checklist
- [ ] Backend specs pass
- [ ] Frontend build passes
- [ ] No pending migrations
- [ ] Preview API SSRF checked
- [ ] BAN behavior checked
- [ ] CSP checked
+8
View File
@@ -0,0 +1,8 @@
# Roadmap
## Public announcement readiness
- Harden preview API
- Tighten material creation permission
- Add admin MVP
- Improve frontend tests
+95
View File
@@ -0,0 +1,95 @@
# frontend/AGENTS.md
## Scope
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
Use only scripts that exist in `package.json`:
```sh
npm run dev
npm run build
npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
After frontend changes, run:
```sh
npm run build
npm run lint
```
If either command cannot be run or fails, report the exact command and failure.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
- Encode URL path-segment values with `encodeURIComponent`.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
- The `@` alias points to `frontend/src`.
- Prefer `@/...` imports for app code instead of long relative paths.
- Keep type imports separate with `import type`.
- Match existing import grouping: external packages, app modules, then type imports.
## Tailwind and UI
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
## Files to avoid in routine work
- Do not edit `dist/` output directly.
- Do not inspect or modify `node_modules/` unless explicitly needed.
- Keep generated build artifacts out of source changes unless the user asks for them.
+11
View File
@@ -8,8 +8,10 @@ import { BrowserRouter,
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
@@ -26,6 +28,8 @@ import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage' import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
import TagListPage from '@/pages/tags/TagListPage' import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -55,7 +59,10 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/> <Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
@@ -132,7 +139,9 @@ export default (() => {
return ( return (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
<BrowserRouter> <BrowserRouter>
<DialogueProvider>
<LayoutGroup> <LayoutGroup>
<motion.div <motion.div
layout="position" layout="position"
@@ -142,7 +151,9 @@ export default (() => {
<RouteTransitionWrapper user={user} setUser={setUser}/> <RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div> </motion.div>
</LayoutGroup> </LayoutGroup>
<Toaster/> <Toaster/>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
}) satisfies FC }) satisfies FC
@@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }:
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
{...attributes} {...attributes}
{...listeners}> {...listeners}>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> <motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)
+98 -16
View File
@@ -3,10 +3,12 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { apiPut } from '@/lib/api' import { toast } from '@/components/ui/use-toast'
import { updatePost } from '@/lib/posts'
import type { FC } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
@@ -31,24 +33,86 @@ type Props = { post: Post
export default (({ post, onSave }: Props) => { export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (post.originalCreatedFrom) useState<string | null> (post.originalCreatedFrom)
const [title, setTitle] = useState (post.title) const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)
const handleSubmit = async () => { const dialogue = useDialogue ()
const data = await apiPut<Post> (
`/posts/${ post.id }`, const update = async (...args: Parameters<typeof updatePost>) => {
{ title, tags, original_created_from: originalCreatedFrom, try
original_created_before: originalCreatedBefore }, {
{ headers: { 'Content-Type': 'multipart/form-data' } }) const data = await updatePost (...args)
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo,
title: data.title, title: data.title,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom, originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post) originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch (e)
{
const response = (e as any)?.response
if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p></p>
<p>?</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
setDisabled (true)
try
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
finally
{
setDisabled (false)
}
} }
useEffect (() => { useEffect (() => {
@@ -56,30 +120,48 @@ export default (({ post, onSave }: Props) => {
}, [post]) }, [post])
return ( return (
<div className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
{/* タイトル */} {/* タイトル */}
<div> <div>
<Label></Label> <Label></Label>
<input type="text" <input
type="text"
disabled={disabled}
className="w-full border rounded p-2" className="w-full border rounded p-2"
value={title} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/> onChange={ev => setTitle (ev.target.value)}/>
</div> </div>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/> <PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/> setOriginalCreatedBefore={setOriginalCreatedBefore}/>
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> type="submit"
disabled={disabled}>
</Button> </Button>
</div>) </form>)
}) satisfies FC<Props> }) satisfies FC<Props>
+13 -5
View File
@@ -3,6 +3,7 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -16,6 +17,8 @@ type Props = {
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
const dialogue = useDialogue ()
const url = new URL (post.url) const url = new URL (post.url)
switch (url.hostname.split ('.').slice (-2).join ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
@@ -82,12 +85,17 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
height={360}/>) height={360}/>)
: ( : (
<div> <div>
<a href="#" onClick={e => { <a href="#" onClick={async e => {
e.preventDefault () e.preventDefault ()
setFramed (confirm ('未確認の外部ページを表示します。\n'
+ '悪意のあるスクリプトが実行される可能性があります。\n' setFramed (await dialogue.confirm ({
+ '表示しますか?')) title: '未確認の外部ページを表示します',
return description: (
<div>
<p></p>
<p>?</p>
</div>),
confirmText: '表示' }))
}}> }}>
</a> </a>
+4 -3
View File
@@ -7,7 +7,7 @@ import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { FC, SyntheticEvent } from 'react' import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -31,12 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }` `${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = { type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string tags: string
setTags: (tags: string) => void } setTags: (tags: string) => void }
export default (({ tags, setTags }: Props) => { export default (({ tags, setTags, ...rest }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -76,6 +76,7 @@ export default (({ tags, setTags }: Props) => {
<div className="relative w-full"> <div className="relative w-full">
<Label></Label> <Label></Label>
<TextArea <TextArea
{...rest}
ref={ref} ref={ref}
value={tags} value={tags}
onChange={ev => setTags (ev.target.value)} onChange={ev => setTags (ev.target.value)}
+5 -2
View File
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => {
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layoutId={layoutId} layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
transform-gpu will-change-transform" 'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => { onLayoutAnimationStart={() => {
if (!(cardRef.current)) if (!(cardRef.current))
@@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
disabled?: boolean
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void } setOriginalCreatedBefore: (x: string | null) => void }
export default (({ originalCreatedFrom, export default (({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore }: Props) => ( setOriginalCreatedBefore }: Props) => (
@@ -21,6 +23,7 @@ export default (({ originalCreatedFrom,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined} value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom} onChange={setOriginalCreatedFrom}
onBlur={ev => { onBlur={ev => {
@@ -40,6 +43,7 @@ export default (({ originalCreatedFrom,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
@@ -51,6 +55,7 @@ export default (({ originalCreatedFrom,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined} value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> onChange={setOriginalCreatedBefore}/>
@@ -58,6 +63,7 @@ export default (({ originalCreatedFrom,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedBefore (null) setOriginalCreatedBefore (null)
}}> }}>
+6 -2
View File
@@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => {
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}> <div className="my-3" key={cat}>
<SubsectionTitle> <SubsectionTitle>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}> <motion.div
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</motion.div> </motion.div>
</SubsectionTitle> </SubsectionTitle>
@@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
</ul> </ul>
</div>))} </div>))}
{post && ( {post && (
<motion.div layoutId={`post-info-${ sp }`}> <motion.div
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<ul> <ul>
<li>Id.: {post.id}</li> <li>Id.: {post.id}</li>
+22 -4
View File
@@ -45,9 +45,9 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null tag.materialId == null && !(tag.hasDeerjikists)
? ( ? (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -55,11 +55,19 @@ export default (({ tag,
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
tag.materialId != null
? (
<PrefetchLink <PrefetchLink
to={`/materials/${ tag.materialId }`} to={`/materials/${ tag.materialId }`}
className={linkClass}> className={linkClass}>
? ?
</PrefetchLink>)) </PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
?
</PrefetchLink>)))
: ( : (
['character', 'material'].includes (tag.category) ['character', 'material'].includes (tag.category)
? ( ? (
@@ -70,6 +78,16 @@ export default (({ tag,
title={`${ tag.name } 素材情報が存在しません.`}> title={`${ tag.name } 素材情報が存在しません.`}>
! !
</PrefetchLink>) </PrefetchLink>)
: (
tag.category === 'deerjikist'
? (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
!
</PrefetchLink>)
: ( : (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -77,7 +95,7 @@ export default (({ tag,
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}> title={`${ tag.name } Wiki が存在しません.`}>
! !
</PrefetchLink>))} </PrefetchLink>)))}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
+17 -8
View File
@@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser' import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki' import { fetchWikiPage } from '@/lib/wiki'
@@ -29,24 +29,31 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? '' const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
return [ return [
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' }, { name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' }, { name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' }, { name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' }, { name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, { name: '全体履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: false, subMenu: [ { name: '素材', to: '/materials', visible: false, subMenu: [
{ name: '一覧', to: '/materials' }, { name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false }, { name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes', visible: false }, { name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' }, { name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
@@ -114,12 +121,14 @@ export default (({ user }: Props) => {
queryKey: wikiKeys.show (wikiIdStr, { }), queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) }) queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const effectiveTitle = wikiPage?.title ?? '' const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''
const { data: tag } = useQuery ({ const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)

Some files were not shown because too many files have changed in this diff Show More