コミットを比較

...

49 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 589fbb0aae #409 2026-07-14 00:54:43 +09:00
みてるぞ 071ef09359 #409 2026-07-14 00:22:41 +09:00
みてるぞ f50134e458 #409 2026-07-13 23:07:07 +09:00
みてるぞ 9a6e6f6053 #409 2026-07-13 12:44:33 +09:00
みてるぞ b7b284c076 スマホでのメニュー表示デザイン・バグ (#405) (#408)
Reviewed-on: #408
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-11 05:02:23 +09:00
みてるぞ 6c451d260f サムネ取得 API 見直し (#398) (#402)
Reviewed-on: #402
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-10 01:27:11 +09:00
みてるぞ c1902fbc99 素材管理で新規タグ紐づけ時にタグ履歴が発生しない問題 (#386) (#392)
開発環境で動作確認したらマージする.

Reviewed-on: #392
2026-07-09 06:43:06 +09:00
みてるぞ 2f2b6e2afa タグ D&D 時の表示位置修正 (#268) (#400)
Reviewed-on: #400
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-08 00:01:07 +09:00
みてるぞ f5b632ed89 設定画面 (#34) (#397)
Reviewed-on: #397
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-07 00:48:41 +09:00
みてるぞ 15619886f2 サイド・バーの幅を可変に (#385) (#396)
Reviewed-on: #396
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-05 03:38:01 +09:00
みてるぞ fdf652951a 【再掲】タグの局所記載 (#351) (#380)
#353 を再開できなかったので.

Reviewed-on: #380
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-03 03:23:36 +09:00
みてるぞ f96ddb65f1 タグが見切れる問題対応 (#76) (#395)
Reviewed-on: #395
2026-07-02 23:40:01 +09:00
みてるぞ 5657fa7e7b “開発モード” の表示 (#389) (#393)
Reviewed-on: #393
2026-07-02 12:47:19 +09:00
みてるぞ 19a185d5b5 上映会タグ表示バグ修正 (#360) (#384)
まだ画面チェックしてゐないのでマージ禁止

Reviewed-on: #384
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-02 01:37:49 +09:00
みてるぞ acd062f289 タグ循環の禁止 (#332) (#382)
Reviewed-on: #382
2026-06-30 01:20:55 +09:00
みてるぞ 877876b661 サムネつきで広場に追加する際にエラーとなる問題修正 (#352) (#390)
Reviewed-on: #390
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-30 01:19:23 +09:00
みてるぞ bffd4c422a 過去のタグ名に名称変更できないバグ修正 (#383) (#387)
Reviewed-on: #387
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-30 01:18:38 +09:00
みてるぞ 776dea87d9 素材管理 (#306) (#381)
開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。

## 0. 先に方針

**やらないこと:**

```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```

これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。

---

## 1. migration 確認

まず現在の状態を見る。

```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```

その後、通常 migration。

```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```

見るポイント:

```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```

確認用:

```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```

ここで `materials without versions=0` になれば、backfill は通っています。

---

## 2. 既存素材一覧の画面確認

フロントを起動して `/materials` を見る。

```sh id="vl28jd"
cd frontend
npm run dev
```

見る観点:

```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```

ここでまず、普通の素材一覧が壊れていないことを確認します。

---

## 3. 左タグバーの確認

`/materials` を開いて左タグバーからタグを選ぶ。

見る観点:

```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```

特に重要なのはこれ。

```txt id="d33os0"
親タグ A を選択
  A に直接紐づく素材
  A > B に紐づく素材
  A > B > C に紐づく素材
が同じ一覧に出ること
```

---

## 4. 選択タグから素材追加

左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。

見る観点:

```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```

Rails console でも確認できます。

```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```

---

## 5. グループ見出しから素材追加

親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。

見る観点:

```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```

ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。

---

## 6. 素材更新と履歴

既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。

見る観点:

```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```

console:

```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
  [v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```

---

## 7. サムネイル

画像素材を追加して、一覧にサムネイルが出るか確認。

動画素材があるなら、`ffmpeg` が入っている環境で backfill。

```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```

見る観点:

```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```

---

## 8. ZIP export

export path がある素材を用意して、ブラウザで確認。

```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```

見る観点:

```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```

---

## 9. 抑止

`/materials/suppressions` で path prefix 抑止を追加する。

見る観点:

```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```

---

## 10. Google Drive 同期

開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿

必要な ENV:

```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```

seed で source 作成、または console で作成。

```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```

見る観点:

```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```

---

## 11. schema.rb は別途確認

これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。

```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```

ここが残るなら、機能テストが通っても merge は止めた方がいいです。

---

## 最小テスト順

時間がないなら、この順で十分です。

```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```

これで、今回の差分の主要な導線はほぼ踏めます。

Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-28 06:35:53 +09:00
みてるぞ ec2b3d2254 タグ “廃止” 追加 (#378) (#379)
Reviewed-on: #379
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-22 08:40:06 +09:00
みてるぞ ffd28c0f9e グカネータ スコア補正 (#376) (#377)
Reviewed-on: #377
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-18 01:15:54 +09:00
みてるぞ a54ca72244 グカネータ改良 (#371) (#375)
Reviewed-on: #375
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-17 01:04:57 +09:00
みてるぞ 5bbd6eda11 グカネータ軽量モード廃止 (#370) (#372)
Reviewed-on: #372
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-15 22:14:08 +09:00
みてるぞ ece95838f0 グカネータ公開 / 洗澡鹿のパス変更 (#361) (#369)
Reviewed-on: #369
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-14 05:40:31 +09:00
みてるぞ 7ab46f907f グカネータ公開 (#361) (#368)
Reviewed-on: #368
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-14 05:33:39 +09:00
みてるぞ e94720941c グカネータ作成 / ウィニング・ラン修正 (#41) (#366)
Reviewed-on: #366
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-12 02:08:59 +09:00
みてるぞ def6870f06 グカネータ / 質問パターン見直し (#41) (#365)
Reviewed-on: #365
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-12 01:35:31 +09:00
みてるぞ c361c561c2 グカネータ作成 / 質問パターン修正 (#41) (#364)
Reviewed-on: #364
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-11 23:21:44 +09:00
みてるぞ 979ccf598e グカネータ作成 / テスト型バグ修正 (#41) (#363)
Reviewed-on: #363
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-10 23:43:50 +09:00
みてるぞ 37ade2a988 グカネータ作成 (#041) (#362)
Reviewed-on: #362
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-10 23:33:56 +09:00
みてるぞ 7d48a8f694 上映会ニコニコ・バグ修正 (#358) (#359)
Reviewed-on: #359
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-07 09:08:41 +09:00
みてるぞ 3980e9651e 上映会改修 (#302) (#357)
Reviewed-on: #357
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-07 02:51:25 +09:00
みてるぞ 750aa40e8e フォームのバリデーションとニコ連携の画面変更 (#090) (#355)
Reviewed-on: #355
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-05 01:59:46 +09:00
みてるぞ dc54f9cbb5 Merge pull request 'フロントのテスト追加 (#155)' (#350) from feature/155 into main
Reviewed-on: #350
2026-05-13 22:03:05 +09:00
みてるぞ 78143363c9 #155 2026-05-13 21:49:40 +09:00
みてるぞ 0a13c00f37 #155 2026-05-13 20:42:25 +09:00
みてるぞ add60cb413 #155 2026-05-11 03:32:47 +09:00
みてるぞ fb761b199d さらに修正 (#346) (#349)
Merge remote-tracking branch 'origin/main' into feature/346

#346

#346

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

#346

#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #349
2026-05-11 02:46:08 +09:00
みてるぞ 73152f2934 ちょっと修正 (#346) (#348)
#346

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

#346

#346

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #348
2026-05-11 02:43:08 +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
みてるぞ 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
390個のファイルの変更44786行の追加4216行の削除
+35
ファイルの表示
@@ -0,0 +1,35 @@
## 背景
なぜ必要か。
## 対象範囲
- 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 を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+996
ファイルの表示
@@ -0,0 +1,996 @@
# AGENTS.md
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
- Backend: Rails API under `backend/`.
- Frontend: React + TypeScript + Vite under `frontend/`.
- Docs: lightweight command notes under `docs/`.
- There is no README or Makefile at the repository root as of this inspection.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording,
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
- `backend/lib/tasks`: custom Rake tasks.
- `backend/spec`: RSpec tests.
- `backend/test`: Rails minitest files that still exist in the tree.
- `frontend/src/App.tsx`: frontend route definitions and initial user setup.
- `frontend/src/pages`: page-level React components.
- `frontend/src/components`: shared and feature components.
- `frontend/src/lib`: API client helpers, query keys, prefetchers, and domain helpers.
- `frontend/src/stores`: Zustand stores.
- `docs/commands.md`: command notes.
## Commands
Only list commands that are backed by files inspected in this repository.
### Backend
The following binstubs exist under `backend/bin`:
```sh
cd backend
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bin/kamal
bin/thrust
```
Common Rails/Rake usage through existing binstubs:
```sh
cd backend
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
bin/rake
bin/rubocop
bin/brakeman
```
RSpec is present in `Gemfile` and `.rspec` exists:
```sh
cd backend
bundle exec rspec
```
### Frontend
The following npm scripts exist in `frontend/package.json`:
```sh
cd frontend
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`node scripts/generate-sitemap.js`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
## Coding style
- Prefer precise, minimal changes.
- Do not flatter or over-explain.
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes
double quotes better.
- For Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 as the default orthography.
- For Japanese kanji spelling, do not apply
《当用漢字による書きかえ》; prefer the original spelling as the formal one.
- Ruby: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`.
- In Ruby, when an `if` condition is split across multiple lines and combines
clauses with `&&` or `||`, wrap the whole condition in parentheses.
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
- Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break.
- Short Ruby hashes may stay visually compact across two lines with the first
pair kept on the opening line and aligned continuation pairs below it.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text.
- TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically.
- In TypeScript and TSX, when breaking a line at an operator, break before the
operator and put the operator at the beginning of the next line. A trailing
operator at end of line is unacceptable. This rule does not apply to Ruby,
where it can change the syntactic structure.
- In TypeScript and TSX, when a function takes one destructured object
argument plus an inline type, prefer this shape when it fits locally:
```ts
const helper = (
{ value, flag }: { value: string
flag: boolean },
): Result => {
// ...
}
```
- In TypeScript and TSX, put `switch` case block braces on their own lines
when a case needs a lexical block:
```ts
case 'yes':
case 'no':
{
const expected = valueFor (item)
return expected == null || expected === answer
}
```
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
only exception.
- In TypeScript and TSX, keep short arrays on one line when they fit under the
line limit; break arrays only when readability or line length requires it.
- In TypeScript and TSX, when a ternary expression is split across multiple
lines, align `?` and `:` with the condition expression. Do not indent `?` and
`:` one extra level under the condition.
```ts
const value =
condition
? consequent
: alternate
```
- In TypeScript and TSX, keep short ternary expressions on one line when they
fit cleanly under the line limit.
- In TypeScript and TSX, prefer ternary expressions for simple conditional
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment.
- When fixing formatting, change formatting only. Do not change expression
structure, control flow, or variable mutability unless the requested style
explicitly requires it.
- Do not add production dependencies without explicit approval.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked.
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behaviour.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
recreates the development database. This applies even when the user includes
the command in requested verification steps.
- Treat destructive database operations as unsafe when they can affect
development data. Ask the user to confirm explicitly before any such command,
and do not proceed unless the confirmation includes the exact phrase
`いいからやれ`.
- Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behaviour changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behaviour.
- Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
- `<a href="#">` is acceptable for event-only controls when it fits the local
UI pattern. Do not use `<a>` for internal navigation or other non-external
links.
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` colour and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text colour for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in
dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow.
- For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
`!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates.
- For user-facing Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 and avoid historical kana spellings unless the task
explicitly requires them.
- For user-facing Japanese kanji spelling, do not normalize to
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TypeScript and TSX style
- The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve the local TypeScript and TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
relying on formatter defaults.
- After every TypeScript or TSX edit, perform a style-only self-review of the
edited hunks before running verification or reporting completion. The task is
not complete while any edited TypeScript or TSX hunk violates these local
formatting rules.
- The TypeScript/TSX self-review must classify every edited leading or trailing
`)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
apply a rule by glyph alone. A closing `)` for a function parameter list is
different from a closing `)` for a function call. A closing `}` for a block
is different from a closing `}` for an object, type literal, import list, or
destructuring pattern.
- The TSX-specific self-review must confirm there are no common Prettier-style
React component declarations with a multi-line destructured parameter.
- The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading indentation follows
4-space logical indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab
character.
- A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
- If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
- If the only line break is inside a parameter's inline type, object type, or
destructuring shape, and the parameter list itself is not split as a separate
block, keep the closing parameter `)` on the same line as that parameter's
final `}`. In this case, moving `)` to a new line is wrong.
- Do not write React component declarations in the common Prettier form
`const Component = ({ ... }) => (` when the destructured parameter spans
multiple lines. Use the project form with the opening `(` after `=`, the
destructured object as the argument, and the closing parameter `)` on its own
line before `=>`.
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
a line except for a multi-line function declaration parameter list.
- Never place a closing square bracket at the beginning of a line.
- For object literals, type literals, import/export named bindings, destructuring
patterns, and other associative-array-style braces, do not place the closing
brace at the beginning of a line. Keep `}` on the same line as the final
property, binding, or specifier unless that would violate the line limit.
Function, lambda, callback, and block closing braces are exempt and should
stay on their own line when that fits the local style. When a function,
lambda, callback, or block body spans multiple lines, do not put its closing
`}` at the end of the previous line.
- For arrays and tuple-like lists, do not place the closing `]` at the
beginning of a line. Keep `]` on the same line as the final element unless
that would violate the line limit.
- For function and method calls, do not place the closing `)` at the beginning
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
multi-line function declaration.
- When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or
`{ key: value }`.
- Do not add inner spaces to React/JSX expression braces, as in
`prop={value}`, `{children}`, or `<Component>{{...props}}</Component>`.
- Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
- Do not insert `void` mechanically before ordinary event-handler or callback
calls merely because the callee returns a `Promise`. If the surrounding code
already uses a plain expression statement such as
`onClick={() => requestActiveTab ('theme')}`, preserve that style unless a
local rule or type requirement truly forces `void`. Adding unnecessary
`void` is a medium-severity style violation.
- When fixing formatting, never change operator binding, evaluation order,
tuple/comma behaviour, or any other expression structure. A formatting pass
that changes how `!==`, `??`, `?:`, `&&`, `||`, or `,` binds is a capital
offence.
- In TypeScript and TSX, do not mechanically verticalise short call tails,
short object literals, or short callback bodies that already fit the local
line-length and delimiter rules. Expanding compact local code without a
readability need is a minor style violation.
- In TypeScript and TSX, treat short object literals, short returned objects,
short typed object shapes, and short function-call object arguments as
compact associative syntax by default. If they fit locally, keep the opening
`{` on the same line as the first property and keep the closing `}` on the
same line as the final property. Do not explode them into vertical
Prettier-style blocks without a concrete readability or line-length reason.
- In TypeScript and TSX, when a compact associative form is broken across two
lines, keep the opening `{` with the first property and the closing `}` with
the final property. A shape like `{\n key: value,\n}` is wrong even when
line length would permit it.
- In TypeScript and TSX, when a short call such as `apiGet<...> (...)`,
`apiPatch<...> (...)`, `setState (...)`, `updateClientSettings (...)`, or
`window.dispatchEvent (...)` already fits within the local style, do not
add extra vertical layers around the object argument or callback body.
- In TypeScript and TSX, after editing a file, inspect the file tail and
remove meaningless blank lines before `export default` or the end of file.
A stray empty line before `export default` is a minor style offence.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
codebase. For example, prefer `import { color: colour } from '@external-lib'`
over spreading `color` through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
### Additional style offences and penalties
Use this section for recurrent failure modes that are easy to miss when
applying the delimiter rules mechanically.
#### Unnecessary `void` before ordinary callbacks
Bad:
```ts
onClick={() => void requestActiveTab ('theme')}
```
Good:
```ts
onClick={() => requestActiveTab ('theme')}
```
Penalty: medium-severity offence. The code still works, but it pollutes the
local style and usually signals that the edit was made mechanically rather than
by reading the surrounding code.
#### Formatting that changes expression structure
Bad:
```ts
const hasUnsavedChanges =
serialiseThemeDraft (draftThemeSlots)
!== (serialiseThemeDraft (savedThemeSlots),
draftActiveThemeMode !== savedThemeMode)
```
Good:
```ts
const hasUnsavedChanges =
serialiseThemeDraft (draftThemeSlots)
!== serialiseThemeDraft (savedThemeSlots)
|| draftThemeMode !== savedThemeMode
```
Penalty: capital offence. Formatting must never corrupt the meaning of an
expression.
#### Mechanical vertical expansion of compact local code
Bad:
```ts
const nextState = {
open,
activeTab }
setDirtyStates (
current => ({
current,
key: value }),
)
```
Good:
```ts
const nextState = { open, activeTab }
setDirtyStates (
current => ({
current,
key: value }))
```
Penalty: minor offence when semantics stay intact. It is not necessarily
broken, but it ignores the repository's compact associative style.
#### Prettier-style exploded object literals that should stay compact
Bad:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings),
}
```
Good:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Also good when short enough:
```ts
const next = { enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Rule: for compact associative syntax, keep `{` with the first property and `}`
with the final property unless line length truly forces a different shape.
Do not mechanically apply the common Prettier block form.
Penalty: medium-severity offence. The code still runs, but it violates a core
repository formatting habit that must be preserved consistently.
#### Exploded object arguments inside short calls
Bad:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next,
}))
```
Good:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next }))
```
Rule: a call-expression closing `)` must not drift onto its own line, and a
short object argument should not be exploded more than necessary. Keep the
callback's returned object compact when it fits.
Penalty: medium-severity offence. This usually means the edit was formatted
mechanically instead of by reading the local style.
#### Stray blank line before `export default`
Bad:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Good:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Rule: do not leave an extra empty line between the last top-level declaration
and `export default` unless some surrounding file structure genuinely requires
visual separation.
Penalty: minor offence. It is small, but it is easy to avoid and should not
recur.
### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
Penalty: capital offence. This is one of the core associative-brace rules.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
Penalty: capital offence. Mistaking a type-literal `}` for a block `}` is one
of the recurring death-class errors.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
Penalty: capital offence. Wrong object-literal `}` placement and wrong call
`)` placement are both death-class violations here.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<Props> = ({
open,
onOpenChange,
}) => {
return null
}
```
Good:
```tsx
const Component: FC<Props> = (
{ open,
onOpenChange },
) => {
return null
}
```
Rule: when a React component or helper takes a destructured object parameter
that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line.
Penalty: capital offence. This pattern is a primary source of repeated TSX
style regressions.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
return null
}
```
Bad:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}) => {
return null
}
```
Rule: this is not a separately split parameter-list block. The line break is
inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property.
Penalty: capital offence. Misclassifying this case means the delimiter review
has failed at the syntax-role level.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key]) => {
return null
}
```
Good:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key],
) => {
return null
}
```
Rule: when the parameter list itself is split across multiple parameter lines,
the closing parameter `)` goes at the beginning of its own line before `=>` or
the return type.
Penalty: capital offence. This is the main allowed exception for a leading `)`,
so getting it wrong destroys the whole rule set.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
Penalty: capital offence. Confusing block `}` with associative `}` is a
death-class delimiter error.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
Penalty: capital offence. A leading call `)` is one of the clearest death-class
violations in this codebase.
#### JSX closing markers
Bad:
```tsx
<Button
type="button"
onClick={handleSave}
>
</Button>
)
```
Good:
```tsx
<Button
type="button"
onClick={handleSave}>
</Button>)
```
Bad:
```tsx
<Input
value={value}
onChange={handleChange}
/>
```
Good:
```tsx
<Input
value={value}
onChange={handleChange}/>
```
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`.
Penalty: medium-severity offence. It is usually not semantically broken, but it
is a conspicuous TSX-style failure.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
Penalty: capital offence. A leading `]` is forbidden by the same class of hard
delimiter rule as leading associative `}` and leading call `)`.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<Component prop={ value }/>
```
Good:
```tsx
<Component prop={value}/>
```
Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces.
Penalty: minor offence. This is not normally semantic breakage, but it still
counts as a style miss.
#### Final TypeScript/TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line. Unless a step explicitly mentions JSX, it applies equally
to `.ts` and `.tsx`:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
Preferred:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behaviour, inspect the related request specs and service objects.
- If frontend code changes, run only non-test verification commands that
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests.
- If backend code changes, do not run RSpec unless the user explicitly asks
for tests.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
A task is complete only when:
- implementation is complete,
- relevant non-test verification commands pass, or failures are clearly
explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behaviour is documented when needed.
+225
ファイルの表示
@@ -0,0 +1,225 @@
# backend/AGENTS.md
## Scope
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands
Use commands backed by files and dependencies in this directory:
```sh
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bundle exec rspec
```
Common checks:
```sh
bundle exec rspec
bin/rubocop
bin/brakeman
```
Common Rails commands:
```sh
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
```
After backend behavior changes, run the relevant RSpec files. For broad backend changes, run:
```sh
bundle exec rspec
```
If a command cannot be run or fails, report the exact command and failure.
Do not create, modify, or run tests unless the user explicitly asks for test
work. When the user asks for tests, keep working and rerun them until they
pass or the remaining failure is clearly blocked.
## Rails structure
- `app/controllers`: API controllers.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit,
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Ruby style
- Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses.
- For `render`-family method calls, omit parentheses even when passing
keyword arguments.
- Never put a line break immediately before `)` in Ruby.
- Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it.
- When an `if` condition is split across multiple lines and combines clauses
with `&&` or `||`, wrap the whole condition in parentheses.
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
rules.
- Do not format Ruby hashes like Ruby blocks.
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
- Keep the first pair on the same line as `{` by default.
- Short Ruby hashes may stay visually compact across two lines with the first
pair kept on the opening line and aligned continuation pairs below it.
- If the hash would exceed the line limit, break after `{` and indent pairs
by 4 spaces.
- Put one logical pair per line when the expression would otherwise become
dense.
- For Ruby arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
## BAN and IP BAN
- `ApplicationController` runs these before actions in order:
- `reject_banned_ip_address!`
- `authenticate_user`
- `reject_banned_user!`
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses only when
the user explicitly asks for tests.
## RSpec
- Prefer RSpec for new backend tests.
- Put API behavior coverage under `spec/requests`.
- Put model behavior under `spec/models`.
- Put service behavior under `spec/services`.
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `AuthHelper#sign_in_as(user)` stubs
`ApplicationController#current_user`; use it when matching existing
request spec style.
- Add or update request specs for API behavior changes only when the user
explicitly asks for tests, especially status codes, permissions, response
shape, and version conflict behavior.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless
explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency,
skips unchanged update snapshots, creates the next version row, and updates
the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics. Cover conflicts in request specs only when the user
explicitly asks for tests.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges,
viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent
implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts.
- Frontend code expects camelCase conversion client-side, while Rails params
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- Be sensitive to N+1 queries in all backend work.
- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when
you find them in the code path you are editing.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
+2
ファイルの表示
@@ -69,3 +69,5 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
+4
ファイルの表示
@@ -306,6 +306,9 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.2)
actionpack (= 8.0.2) actionpack (= 8.0.2)
activesupport (= 8.0.2) activesupport (= 8.0.2)
@@ -477,6 +480,7 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rack-cors rack-cors
rails (~> 8.0.2) rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0) rspec-rails (~> 8.0)
rubocop-rails-omakase rubocop-rails-omakase
sprockets-rails sprockets-rails
+67 -4
ファイルの表示
@@ -1,14 +1,19 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
before_action :authenticate_user rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
def current_user before_action :reject_banned_ip_address!
@current_user before_action :authenticate_user
end before_action :reject_banned_user!
def current_user = @current_user
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 +27,62 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end end
def render_bad_request message = 'リクエストが不正です.'
render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end
def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_validation_error(fields: field ? { field => [message] } : { },
base: field ? [] : [message])
end
def render_record_invalid error
render_validation_error error.record
end
def render_record_not_unique _error = nil
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
record.errors.each do |error|
errors[error.attribute] ||= []
errors[error.attribute] << error.message
end
end
fields.each do |attr, messages|
errors[attr.to_sym] ||= []
errors[attr.to_sym].concat(Array(messages))
end
base_errors = Array(base) + Array(errors.delete(:base))
render json: { type: 'validation_error',
message: '入力内容を確認してください.',
errors:,
base_errors: },
status:
end
end end
+7 -3
ファイルの表示
@@ -2,7 +2,8 @@ class DeerjikistsController < ApplicationController
def show def show
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank? return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
deerjikist = Deerjikist deerjikist = Deerjikist
.joins(:tag) .joins(:tag)
@@ -22,7 +23,9 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i tag_id = params[:tag_id].to_i
return head :bad_request if platform.blank? || code.blank? || tag_id <= 0 return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.') if tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id d.tag_id = tag_id
@@ -38,7 +41,8 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank? return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
Deerjikist.find([platform, code]).destroy! Deerjikist.find([platform, code]).destroy!
+272
ファイルの表示
@@ -0,0 +1,272 @@
class GekanatorGamesController < ApplicationController
def create
return head :unauthorized unless current_user
guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence
answers = params.require(:answers).as_json
game = GekanatorGame.new(
user: current_user,
guessed_post_id:,
correct_post_id:,
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
question_count: answers.length,
answers:)
if game.invalid?
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
return
end
learned_example_count = 0
ActiveRecord::Base.transaction do
game.save!
learned_example_count = learn_answers_from_game!(game)
end
render json: {
id: game.id,
learned_example_count:
}, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
def extra_questions
game = find_owned_game
return if performed?
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.where(kind: 'post_similarity', source: 'user_suggested')
.to_a
selected =
prioritized_extra_questions(
questions,
post_id: game.correct_post_id,
user: current_user,
limit: 6)
render json: {
questions: selected.map { |question| extra_question_json(question) }
}
end
def extra_question_answers
game = find_owned_game
return if performed?
answer_params = params.require(:answers)
if !answer_params.is_a?(Array)
return render_validation_error fields: { answers: ['配列で指定してください.'] }
end
answers = answer_params.map { |answer|
{
question_id: answer.require(:question_id).to_i,
answer: answer.require(:answer)
}
}
questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] })
question_by_id = questions.index_by(&:id)
if questions.length != answers.length
return render_validation_error fields: { answers: ['質問が見つかりません.'] }
end
if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' }
return render_validation_error fields: { answers: ['質問が不正です.'] }
end
ActiveRecord::Base.transaction do
answers.each do |item|
question = question_by_id[item[:question_id]]
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.record_answer!(
answer: item[:answer],
source: 'post_game_extra',
gekanator_game: game)
example.save!
end
end
render json: { count: answers.length }, status: :created
end
private
def extra_question_json question
{
id: question.id,
text: question.text,
source: question.source,
priority_weight: question.priority_weight
}
end
def prioritized_extra_questions questions, post_id:, user:, limit:
answered_question_ids =
GekanatorQuestionExample
.where(user:, gekanator_question_id: questions.map(&:id))
.distinct
.pluck(:gekanator_question_id)
unanswered, answered =
questions.partition { |question| !answered_question_ids.include?(question.id) }
selected = weighted_sample_questions(unanswered, post_id:, limit:)
return selected if selected.length >= limit
selected + weighted_sample_questions(
answered.reject { |question| selected.any? { _1.id == question.id } },
post_id:,
limit: limit - selected.length)
end
def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id)
selected = []
while selected.length < limit && remaining.any?
weighted =
remaining.map { |question|
[question, selection_weight_for(question, post_id: post_id)]
}
total_weight = weighted.sum { |_question, weight| weight }
break if total_weight <= 0
target = rand * total_weight
cumulative = 0.0
chosen =
weighted.find do |_question, weight|
cumulative += weight
cumulative >= target
end&.first || weighted.first.first
selected << chosen
remaining.reject! { |question| question.id == chosen.id }
end
selected
end
def selection_weight_for question, post_id:
sample_count =
question.gekanator_question_examples.sum { |example|
next 0 unless example.post_id == post_id
example.sample_count.presence || 1
}
question.priority_weight.to_f / (1.0 + sample_count * 0.15)
end
def find_owned_game
return head :unauthorized unless current_user
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
if !current_user.admin? && game.user_id != current_user.id
return head :not_found
end
game
end
def learn_answers_from_game! game
correct_post = game.correct_post
return 0 if correct_post.blank?
accepted_questions =
GekanatorQuestion
.accepted
.index_by { |question| public_question_id_for(question) }
learned_count = 0
Array(game.answers).each do |answer|
answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown'
question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question)
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: correct_post,
user: current_user)
example.record_answer!(
answer: answer_value,
source: 'post_game_answer',
gekanator_game: game)
example.save!
learned_count += 1
end
learned_count
end
def public_question_id_for question
condition = normalize_condition(question.condition)
case condition[:type]
when 'tag'
"tag:#{condition[:key]}"
when 'source'
"source:#{condition[:host]}"
when 'original-year'
"original-year:#{condition[:year]}"
when 'original-month'
"original-month:#{condition[:month]}"
when 'original-month-day'
"original-month-day:#{condition[:monthDay] || condition[:month_day]}"
when 'title-length-at-least'
"title:length-at-least:#{condition[:length]}"
when 'title-length-greater-than'
"title:length-at-least:#{condition[:length].to_i + 1}"
when 'title-has-ascii'
'title:ascii'
when 'title-contains'
"title:contains:#{condition[:text]}"
when 'post-similarity'
"post-similarity:#{question.id}"
else
"catalog:#{question.id}"
end
end
def normalize_condition condition
json = condition.deep_dup.as_json
if json['type'] == 'original-month-day' && json['monthDay'].blank?
json['monthDay'] = json.delete('month_day')
end
json.deep_symbolize_keys
end
def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
condition = normalize_condition(question.condition)
key = condition[:key].to_s
!key.start_with?('nico:')
end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end
+71
ファイルの表示
@@ -0,0 +1,71 @@
class GekanatorPostsController < ApplicationController
def index
posts =
Post
.preload(:post_similarities, tags: :tag_name)
.with_attached_thumbnail
.order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
.to_a
active_tags_by_post_id =
posts.each_with_object({ }) do |post, h|
h[post.id] = post.tags.reject(&:deprecated?)
end
render json: {
posts: posts.map { |post|
post_json(post,
active_tags_by_post_id:)
}
}
end
private
def post_json post, active_tags_by_post_id:
{
id: post.id,
url: post.url,
title: post.title,
thumbnail: thumbnail_url(post),
thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
post_similarity_edges: post_similarity_edges_json(
post,
active_tags_by_post_id:),
tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
}
end
def post_similarity_edges_json post, active_tags_by_post_id:
post
.post_similarities
.filter_map do |similarity|
next unless active_tags_by_post_id.key?(similarity.target_post_id)
{
target_post_id: similarity.target_post_id,
cos: similarity.cos.to_f
}
end
end
def tag_json tag
{
id: tag.id,
name: tag.name,
category: tag.category
}
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
rescue
nil
end
end
+95
ファイルの表示
@@ -0,0 +1,95 @@
class GekanatorQuestionSuggestionsController < ApplicationController
def create
return head :unauthorized unless current_user
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
return head :not_found unless game
if !current_user.admin? && game.user_id != current_user.id
return head :not_found
end
existing_question_id = params[:existing_question_id].presence
if existing_question_id
question = GekanatorQuestion.accepted.find_by(id: existing_question_id)
return head :not_found unless question
unless learnable_existing_question?(question)
return render_validation_error fields: { existing_question_id: ['質問が不正です.'] }
end
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.record_answer!(
answer: params.require(:answer),
source: 'post_game_extra',
gekanator_game: game)
if example.save
render json: {
id: question.id,
count: game.question_suggestions.count
}, status: :created
else
render_validation_error example
end
return
end
suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game,
user: current_user,
question_text: params.require(:question_text),
answer: params.require(:answer))
if suggestion.valid?
ActiveRecord::Base.transaction do
suggestion.save!
Gekanator::QuestionSuggestionPromoter.call(
suggestion: suggestion,
user: current_user)
end
render json: {
id: suggestion.id,
count: game.question_suggestions.count
}, status: :created
else
render_validation_error suggestion
end
end
def ai_convert
return head :not_found unless current_user&.admin?
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
return head :not_found unless suggestion
if Gekanator::AiRunBudget.exceeded_after_next_run?
suggestion.gekanator_ai_runs.create!(
model: 'budget_guard',
status: 'blocked_budget',
input_tokens: 0,
output_tokens: 0,
estimated_cost_jpy: 0)
return head :payment_required
end
Gekanator::QuestionSuggestionAiConverter.call(
suggestion: suggestion,
user: current_user)
head :no_content
rescue NotImplementedError
head :not_implemented
end
private
def learnable_existing_question? question
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
key = question.condition.as_json['key'].to_s
!key.start_with?('nico:')
end
end
+147
ファイルの表示
@@ -0,0 +1,147 @@
class GekanatorQuestionsController < ApplicationController
def index
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.order(priority_weight: :desc, id: :asc)
.to_a
deprecated_tag_keys = deprecated_tag_keys_for(questions)
render json: {
questions: questions.filter_map { |question|
json = question_json(question)
next if hidden_question?(json[:condition], deprecated_tag_keys)
json
}
}
end
private
def question_json question
condition = condition_json(question.condition).deep_symbolize_keys
json = {
record_id: question.id,
id: question_id_for(question, condition),
text: question_text_for(question, condition),
kind: question.kind,
condition: condition,
source: question.source,
priority_weight: question.priority_weight
}
if question.kind == 'post_similarity' || question.kind == 'tag'
json[:example_answers] = example_answers_json(question)
end
json
end
def question_id_for question, condition
case condition[:type]
when 'tag'
"tag:#{ condition[:key] }"
when 'source'
"source:#{ condition[:host] }"
when 'original-year'
"original-year:#{ condition[:year] }"
when 'original-month'
"original-month:#{ condition[:month] }"
when 'original-month-day'
"original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
when 'title-length-at-least'
"title:length-at-least:#{ condition[:length] }"
when 'title-length-greater-than'
"title:length-at-least:#{ condition[:length].to_i + 1 }"
when 'title-has-ascii'
'title:ascii'
when 'title-contains'
"title:contains:#{ condition[:text] }"
when 'post-similarity'
"post-similarity:#{ question.id }"
else
"catalog:#{ question.id }"
end
end
def condition_json condition
json = condition.deep_dup.as_json
if json['type'] == 'original-month-day' && json['monthDay'].blank?
json['monthDay'] = json.delete('month_day')
end
if json['type'] == 'title-length-greater-than'
json['type'] = 'title-length-at-least'
json['length'] = json['length'].to_i + 1
end
json
end
def question_text_for question, condition
return question.text unless question.kind == 'title'
case condition[:type]
when 'title-length-at-least'
"タイトルは #{ condition[:length] } 文字以上?"
when 'title-contains'
"題名に「#{ condition[:text] }」が含まれる?"
else
question.text
end
end
def example_answers_json question
question
.gekanator_question_examples
.group_by(&:post_id)
.transform_values { |examples| aggregate_answer(examples) }
end
def aggregate_answer examples
examples
.group_by(&:answer)
.map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
.sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
.first
&.first
end
def deprecated_tag_keys_for questions
tag_keys = questions.filter_map { |question|
condition = condition_json(question.condition)
next unless condition['type'] == 'tag'
condition['key'].to_s.presence
}.uniq
return {} if tag_keys.empty?
categories = []
names = []
tag_keys.each do |key|
category, name = parse_tag_key(key)
categories << category
names << name
end
Tag
.joins(:tag_name)
.where(category: categories.uniq)
.where(tag_names: { name: names.uniq })
.where.not(deprecated_at: nil)
.pluck('tags.category', 'tag_names.name')
.each_with_object({ }) do |(category, name), h|
h["#{ category }:#{ name }"] = true
end
end
def hidden_question? condition, deprecated_tag_keys
condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s]
end
def parse_tag_key key
parts = key.to_s.split(':')
[parts.first.to_s, parts.drop(1).join(':')]
end
end
+49
ファイルの表示
@@ -0,0 +1,49 @@
class MaterialSyncSuppressionsController < ApplicationController
def index
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
suppressions =
MaterialSyncSuppression
.includes(:created_by_user)
.order(created_at: :desc, id: :desc)
render json: { suppressions: suppressions.map { |s| repr(s) } }
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
suppression =
MaterialSyncSuppressionRegistrar.create!(suppression_params,
created_by_user: current_user)
render json: repr(suppression), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
end
private
def suppression_params
params.permit(:source_kind,
:source_uri,
:drive_path,
:drive_file_id,
:reason)
end
def repr suppression
{ id: suppression.id,
source_kind: suppression.source_kind,
source_uri: suppression.source_uri,
drive_path: suppression.drive_path,
drive_file_id: suppression.drive_file_id,
normalized_source_key: suppression.normalized_source_key,
reason: suppression.reason,
created_by_user: suppression.created_by_user&.as_json(UserRepr::BASE),
created_at: suppression.created_at,
updated_at: suppression.updated_at }
end
end
+54
ファイルの表示
@@ -0,0 +1,54 @@
class MaterialVersionsController < ApplicationController
def index
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 = MaterialVersion.includes(:created_by_user)
q = q.where(material_id: params[:material_id]) if params[:material_id].present?
q = q.where(tag_name: params[:tag].to_s.strip) if params[:tag].present?
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
count = q.except(:order, :limit, :offset).count
versions =
q
.order(Arel.sql('material_versions.created_at DESC, material_versions.id DESC'))
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
end
private
def serialise_versions rows
rows.map do |row|
{ id: row.id,
material_id: row.material_id,
version_no: row.version_no,
event_type: row.event_type,
tag_id: row.tag_id,
tag_name: row.tag_name,
tag_category: row.tag_category,
url: row.url,
source_kind: row.source_kind,
source_uri: row.source_uri,
source_path: row.source_path,
source_file_id: row.source_file_id,
normalized_source_key: row.normalized_source_key,
file_blob_id: row.file_blob_id,
file_filename: row.file_filename,
file_content_type: row.file_content_type,
file_byte_size: row.file_byte_size,
file_sha256: row.file_sha256,
export_paths_json: row.export_paths_hash,
discarded_at: row.discarded_at,
created_by_user: row.created_by_user&.as_json(UserRepr::BASE),
created_at: row.created_at }
end
end
end
+482 -36
ファイルの表示
@@ -1,4 +1,11 @@
require 'set'
class MaterialsController < ApplicationController class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index def index
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -8,53 +15,101 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tag_id = params[:tag_id].presence filters = material_index_filters
parent_id = params[:parent_id].presence tag_graph = material_index_tag_graph(filters)
q = Material.includes(:material_export_items,
thumbnail_attachment: :blob,
file_attachment: :blob,
tag: :tag_name)
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = apply_material_tag_filter(q, filters, tag_graph)
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to]
q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from]
q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to]
q = Material.includes(:tag, :created_by_user).with_attached_file q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
q = q.where(tag_id:) if tag_id q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
q = q.where(parent_id:) if parent_id q = apply_material_query(q, filters[:q]) if filters[:q].present?
q = apply_material_media_kind(q, filters[:media_kind])
count = q.count count = q.distinct.count(:id)
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) materials =
q
.order(Arel.sql(material_index_order_sql(filters)))
.limit(limit)
.offset(offset)
.to_a
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } response = { materials: MaterialRepr.list_many(materials, host: request.base_url),
count: }
if filters[:tag_id]
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
end
if filters[:group_by] == 'parent_tag'
response[:groups] = material_index_groups(materials, tag_graph)
end
render json: response
end end
def show def show
material = material =
Material Material
.includes(:tag) .includes(:tag, :material_export_items)
.with_attached_thumbnail
.with_attached_file .with_attached_file
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?) return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
tag = tag_name.tag material = nil
tag = Tag.create!(tag_name:, category: :material) unless tag
material = Material.new(tag:, url:, begin
created_by_user: current_user, Material.transaction do
updated_by_user: current_user) tag = resolve_material_tag!(tag_name_raw)
material.file.attach(file) material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end
if material.save if material
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity render_validation_error material
end end
end end
@@ -67,25 +122,42 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence file_sha256 = MaterialFileSha256.from_upload(file)
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?) url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank? && !material.file.attached?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.update!(tag:, url:, updated_by_user: current_user) begin
if file Material.transaction do
material.file.attach(file) MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
else tag = resolve_material_tag!(tag_name_raw)
material.file.purge material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
elsif params.key?(:url) && url.present? && file.blank?
material.file.detach
end
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end end
if material.save log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url)
else render json: MaterialRepr.base(material, host: request.base_url)
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end end
def destroy def destroy
@@ -95,7 +167,381 @@ class MaterialsController < ApplicationController
material = Material.find_by(id: params[:id]) material = Material.find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
material.discard Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.discard!
MaterialVersionRecorder.record!(material:, event_type: :discard,
created_by_user: current_user)
end
head :no_content head :no_content
end end
def download
zip = MaterialZipExporter.new(profile: params[:profile],
tag_id: params[:tag_id]).export
profile = params[:profile].presence || 'legacy_drive'
send_data zip,
type: 'application/zip',
disposition: 'attachment',
filename: "btrc-materials-#{ profile }.zip"
end
private
def material_index_filters
tag_state = params[:tag_state].to_s.presence
tag_state = 'untagged' if bool?(:unclassified)
tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state)
media_kind = params[:media_kind].to_s.presence
unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind)
media_kind = 'all'
end
sort = params[:sort].to_s.presence
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'].include?(sort)
sort = 'created_at'
end
direction = params[:direction].to_s.downcase
direction = 'desc' unless ['asc', 'desc'].include?(direction)
group_by = params[:group_by].to_s.presence
group_by = 'none' unless ['none', 'parent_tag'].include?(group_by)
tag_id = params[:tag_id].to_i
tag_id = nil if tag_id <= 0
{ q: params[:q].to_s.strip.presence,
tag_state:,
media_kind:,
tag_id:,
include_descendants: bool?(:include_descendants),
group_by:,
created_from: parse_time_param(:created_from),
created_to: parse_time_param(:created_to),
updated_from: parse_time_param(:updated_from),
updated_to: parse_time_param(:updated_to),
sort:,
direction: }
end
def parse_time_param name
value = params[name].to_s.strip
return nil if value.blank?
Time.zone.parse(value)
rescue ArgumentError
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
def material_index_needs_file_blob? filters
filters[:q].present? ||
filters[:media_kind] != 'all' ||
['media_kind', 'file_byte_size'].include?(filters[:sort])
end
def material_index_join_tag_name q
q.left_joins(tag: :tag_name)
end
def material_index_join_file_blob q
q.joins(<<~SQL.squish)
LEFT JOIN active_storage_attachments material_file_attachments
ON material_file_attachments.record_type = 'Material'
AND material_file_attachments.record_id = materials.id
AND material_file_attachments.name = 'file'
LEFT JOIN active_storage_blobs material_file_blobs
ON material_file_blobs.id = material_file_attachments.blob_id
SQL
end
def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
'material_file_blobs.filename LIKE :q',
q: like)
end
def apply_material_media_kind q, media_kind
case media_kind
when 'image'
q.where('material_file_blobs.content_type LIKE ?', 'image/%')
when 'video'
q.where('material_file_blobs.content_type LIKE ?', 'video/%')
when 'audio'
q.where('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'file_other'
q.where('material_file_attachments.id IS NOT NULL')
.where.not('material_file_blobs.content_type LIKE ?', 'image/%')
.where.not('material_file_blobs.content_type LIKE ?', 'video/%')
.where.not('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'url_only'
q.where('material_file_attachments.id IS NULL').where.not(url: [nil, ''])
else
q
end
end
def apply_material_tag_filter q, filters, tag_graph
return q unless filters[:tag_id]
tag_ids =
if tag_graph
tag_graph[:scope_tag_ids]
else
[filters[:tag_id]]
end
q.where(tag_id: tag_ids)
end
def material_index_tag_graph filters
return nil unless filters[:tag_id]
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
children_by_parent_id[parent_id] << child_id
end
scope_tag_ids =
if filters[:include_descendants]
collect_material_descendant_ids(filters[:tag_id], children_by_parent_id)
else
Set.new([filters[:tag_id]])
end
tags_by_id =
Tag
.joins(:tag_name)
.where(id: scope_tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), hash|
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
end
selected_tag = tags_by_id[filters[:tag_id]]
return nil unless selected_tag
group_tag_id_by_tag_id = { }
children_by_parent_id[filters[:tag_id]].each do |child_tag_id|
assign_material_group_branch(child_tag_id,
children_by_parent_id:,
tags_by_id:,
group_tag_id_by_tag_id:,
current_group_tag_id: nil,
seen: Set.new([filters[:tag_id]]))
end
{ selected_tag_id: filters[:tag_id],
selected_tag:,
scope_tag_ids: scope_tag_ids.to_a,
tags_by_id:,
group_tag_id_by_tag_id: }
end
def collect_material_descendant_ids selected_tag_id, children_by_parent_id
ids = Set.new([selected_tag_id])
stack = [selected_tag_id]
until stack.empty?
tag_id = stack.pop
children_by_parent_id[tag_id].each do |child_tag_id|
next if ids.include?(child_tag_id)
ids << child_tag_id
stack << child_tag_id
end
end
ids
end
def assign_material_group_branch tag_id, children_by_parent_id:, tags_by_id:,
group_tag_id_by_tag_id:, current_group_tag_id:, seen:
return if seen.include?(tag_id)
tag = tags_by_id[tag_id]
return unless tag
seen = seen.dup << tag_id
next_group_tag_id = current_group_tag_id
next_group_tag_id = tag_id if next_group_tag_id.nil? && !tag[:deprecated]
group_tag_id_by_tag_id[tag_id] = next_group_tag_id if next_group_tag_id
children_by_parent_id[tag_id].each do |child_tag_id|
assign_material_group_branch(child_tag_id,
children_by_parent_id:,
tags_by_id:,
group_tag_id_by_tag_id:,
current_group_tag_id: next_group_tag_id,
seen:)
end
end
def material_index_groups materials, tag_graph
return [] unless tag_graph
groups_by_key = { }
materials.each do |material|
next unless material.tag_id
group_tag_id =
if material.tag_id == tag_graph[:selected_tag_id]
tag_graph[:selected_tag_id]
else
tag_graph[:group_tag_id_by_tag_id][material.tag_id] || tag_graph[:selected_tag_id]
end
group_tag = tag_graph[:tags_by_id][group_tag_id]
next unless group_tag
key = "tag:#{ group_tag_id }"
group =
groups_by_key[key] ||= { key:,
tag: group_tag_repr(group_tag),
material_ids: [],
count: 0 }
group[:material_ids] << material.id
group[:count] += 1
end
groups_by_key.values.sort_by { |group| group[:tag][:name] }
end
def material_index_tag_scope filters, tag_graph
return nil unless tag_graph
{
tag: group_tag_repr(tag_graph[:selected_tag]),
include_descendants: filters[:include_descendants]
}
end
def group_tag_repr group_tag
{ id: group_tag[:id],
name: group_tag[:name],
category: group_tag[:category] }
end
def material_index_order_sql filters
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
sort_sql =
case filters[:sort]
when 'tag_name'
'tag_names.name'
when 'media_kind'
material_media_kind_sql
when 'file_byte_size'
'material_file_blobs.byte_size'
when 'updated_at'
'materials.updated_at'
when 'version_no'
'materials.version_no'
when 'id'
'materials.id'
else
'materials.created_at'
end
"#{ sort_sql } #{ direction }, materials.id #{ direction }"
end
def material_media_kind_sql
"CASE " \
"WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \
"WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \
"WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \
"WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \
"WHEN material_file_attachments.id IS NOT NULL THEN 4 " \
"ELSE 6 END"
end
def upsert_export_paths! material
raw = params[:export_paths]
return if raw.blank?
export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
export_paths.each do |profile, export_path|
profile = profile.to_s
export_path = export_path.to_s.strip
item = material.material_export_items.find_or_initialize_by(profile:)
if export_path.blank?
item.destroy! if item.persisted?
next
end
item.export_path = export_path
item.enabled = true
item.created_by_user ||= current_user
item.save!
end
end
def render_zip_empty
render_unprocessable_entity('ZIP export 対象の素材がありません.')
end
def render_zip_duplicate_path error
render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }")
end
def render_zip_missing_file error
missing_files = error.missing_files.map do |missing_file|
{ material_id: missing_file.material_id,
export_path: missing_file.export_path,
blob_id: missing_file.blob_id,
filename: missing_file.filename }
end
render json: { type: 'validation_error',
message: 'ZIP export に必要な素材ファイルが欠損しています.',
errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: },
status: :unprocessable_entity
end
def build_uploaded_material_blob! file, file_sha256
return nil unless file
file.tempfile.rewind
blob = ActiveStorage::Blob.create_and_upload!(
io: file.tempfile,
filename: file.original_filename,
content_type: file.content_type,
)
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
blob
ensure
file.tempfile.rewind if file&.tempfile
end
def render_material_import_block block
render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] }
end
def log_thumbnail_generation material, result
material.reload
Rails.logger.info(
"Material thumbnail generation: material_id=#{ material.id } " \
"result=#{ result } " \
"thumbnail_attached=#{ material.thumbnail.attached? } " \
"content_type=#{ material.content_type }")
end
end end
+82 -19
ファイルの表示
@@ -1,26 +1,69 @@
class NicoTagsController < ApplicationController class NicoTagsController < ApplicationController
def index def index
limit = (params[:limit] || 20).to_i name = params[:name].presence
cursor = params[:cursor].presence linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q = Tag.nico_tags q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
.order(updated_at: :desc) q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor if linked_tag
linked_tag_ids =
tags = q.limit(limit + 1).to_a Tag
.joins(:tag_name)
next_cursor = nil .where('tag_names.name LIKE ?', "%#{ linked_tag }%")
if tags.size > limit .pluck(:id)
next_cursor = tags.last.updated_at.iso8601(6) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
tags = tags.first(limit) q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
count = q.count
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt| TagRepr.base(tag).merge(
TagRepr.base(lt) recent_post_tag_created_at: tag.recent_post_tag_created_at,
}) linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, next_cursor: } }, count: }
end end
def update def update
@@ -30,14 +73,18 @@ 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 unless tag.nico? return render_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 = nil
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
@@ -47,5 +94,21 @@ class NicoTagsController < ApplicationController
end 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
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end end
end end
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .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.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base, current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base'] prev: row.attributes['prev_thumbnail_base']
}, },
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
+479 -34
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index def index
url = params[:url].presence url = params[:url].presence
title = params[:title].presence title = params[:title].presence
@@ -44,7 +48,10 @@ 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: [:materials, { tag_name: :wiki_page }]) .preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [: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 +102,11 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -104,12 +115,27 @@ class PostsController < ApplicationController
end end
def show def show
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) post =
Post
.includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
render json: PostRepr.base(post, current_user) parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a
.merge(tags: build_tag_tree_for(post.tags), child_posts = post.children.with_attached_thumbnail.order(:id).to_a
related: post.related(limit: 20)) sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id))
related = post.related(limit: 20).to_a
render json: PostRepr.detail(post, current_user,
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post))
end end
def create def create
@@ -123,28 +149,47 @@ 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
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
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(resized_thumbnail) if resized_thumbnail
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
tags = Tag.normalise_tags(tag_names)
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags) post.video_ms = normalise_video_ms(tags)
post.resized_thumbnail! validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
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 render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record
end end
def viewed def viewed
@@ -165,35 +210,84 @@ 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 render_bad_request('force と merge は同時に指定できません.') if force && merge
base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
title = params[:title].presence 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
ApplicationRecord.transaction do ApplicationRecord.transaction do
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) post = Post.lock.find(params[:id].to_i)
post.update!(title:, original_created_from:, original_created_before:) base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) base_snapshot = post_snapshot_from_version(base_version)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:)
tags = post.tags.nico.to_a + normalised_tags snapshot_to_apply =
tags = Tag.expand_parent_tags(tags) if force || post.version_no == base_version_no || current_snapshot == base_snapshot
sync_post_tags!(post, tags) incoming_snapshot
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end
apply_post_snapshot!(post, snapshot_to_apply)
end 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)
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 render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record
end end
def changes def changes
@@ -211,7 +305,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: [:materials, { tag_name: :wiki_page }]) tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = [] events = []
pts.each do |pt| pts.each do |pt|
@@ -288,7 +382,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) = def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t| desired_tags.each do |t|
t.save! if t.new_record? t.save! if t.new_record?
end end
@@ -307,13 +401,21 @@ class PostsController < ApplicationController
end end
end end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user) pt.discard_by!(current_user)
end end
end end
def build_tag_tree_for tags def build_tag_tree_for post
tags = tags.to_a post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -328,6 +430,9 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id) tags_by_id = tags.index_by(&:id)
sections_by_tag_id = post_tags.to_h { |post_tag|
[post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
}
memo = { } memo = { }
@@ -335,8 +440,10 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id] tag = tags_by_id[tag_id]
return nil unless tag return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.base(tag).merge(children: []) return TagRepr.inline(tag).merge(children: [], sections:)
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -348,9 +455,347 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.base(tag).merge(children:) memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
end end
def sibling_posts_by_parent parent_post_ids
return { } if parent_post_ids.blank?
implications =
PostImplication
.where(parent_post_id: parent_post_ids)
.includes(post: { thumbnail_attachment: :blob })
.order(:parent_post_id, :post_id)
implications.group_by(&:parent_post_id).transform_values { |items|
items.map(&:post)
}
end
def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
raise ActiveRecord::RecordInvalid, post
end
existing_ids = Post.where(id: parent_post_ids).pluck(:id)
missing_ids = parent_post_ids - existing_ids
if missing_ids.present?
post.errors.add :parent_post_ids,
"存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post
end
current_ids = post.parent_posts.pluck(:id)
ids_to_add = parent_post_ids - current_ids
ids_to_remove = current_ids - parent_post_ids
PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
ids_to_add.each do |parent_post_id|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
end
end
def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false)
if version_no&.positive?
version_no
else
nil
end
end
def post_snapshot_from_version version
{ title: version.title,
video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
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,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
validate_video_sections!(video_ms, sections)
{ title:,
video_ms:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: tags.uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort,
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 section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
base_version_no:,
current_version_no: post.version_no,
base: base_snapshot,
current: current_snapshot,
mine: incoming_snapshot,
changes:,
conflicts:,
mergeable: conflicts.empty? }
end
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
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],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :video_ms, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine
raise ArgumentError, '競合してゐる項目はマージできません.'
end
def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me
merged.uniq.sort
end
def render_post_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
return nil unless tags.any? { _1.id == Tag.video.id }
if video_ms_param.present?
video_ms = Integer(video_ms_param, exception: false)
raise VideoMsParseError unless video_ms&.positive?
return video_ms
end
return nil if duration_param.blank?
video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
raise VideoMsParseError unless video_ms.positive?
video_ms
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
end end
+45 -34
ファイルの表示
@@ -1,46 +1,57 @@
class PreviewController < ApplicationController class PreviewController < ApplicationController
before_action :require_member!
def title def title
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに. return render_bad_request('URL は必須です.') if params[:url].blank?
return head :unauthorized unless current_user
url = params[:url] render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
return head :bad_request unless url.present? rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
unless url.start_with?(/http(s)?:\/\//) rescue Preview::HttpFetcher::FetchTimeout => e
url = 'http://' + url render_preview_error(e.message, :gateway_timeout)
end rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
html = URI.open(url, open_timeout: 5, read_timeout: 5).read rescue Preview::HttpFetcher::FetchFailed => e
doc = Nokogiri::HTML.parse(html) render_preview_error(e.message, :bad_gateway)
title = doc.at('title')&.text&.strip
render json: { title: title }
rescue => e
render json: { error: e.message }, status: :bad_request
end end
def thumbnail def thumbnail
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする. return render_bad_request('URL は必須です.') if params[:url].blank?
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
image.auto_orient
image.resize '180x180>'
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::ResponseTooLarge => e
render_preview_error(e.message, :payload_too_large)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
render_unprocessable_entity(e.message)
end
private
def require_member!
return head :unauthorized unless current_user return head :unauthorized unless current_user
return if current_user.gte_member?
url = params[:url] head :forbidden
return head :bad_request if url.blank? end
unless url.start_with?(/http(s)?:\/\//) def render_preview_error(message, status)
url = 'http://' + url render json: { type: status.to_s,
end message:,
errors: { },
path = Rails.root.join('tmp', "thumb_#{ SecureRandom.hex }.png") base_errors: [message] },
system("node #{ Rails.root }/lib/screenshot.js #{ Shellwords.escape(url) } #{ path }") status:
if File.exist?(path)
image = MiniMagick::Image.open(path)
image.resize '180x180'
File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline'
else
render json: { error: 'Failed to generate thumbnail' }, status: :internal_server_error
end
end end
end end
+6 -4
ファイルの表示
@@ -5,11 +5,12 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico? return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -27,11 +28,12 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico? return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+95
ファイルの表示
@@ -0,0 +1,95 @@
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.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
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'] },
deprecated_at: { current: row.deprecated_at&.iso8601,
prev: row.attributes['prev_deprecated_at']&.iso8601 },
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
parent_tags:,
created_at: row.created_at.iso8601,
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
+416 -67
ファイルの表示
@@ -1,3 +1,8 @@
require 'net/http'
require 'uri'
require 'set'
class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -10,6 +15,8 @@ class TagsController < ApplicationController
post_count_between[1] = nil if post_count_between[1] < 0 post_count_between[1] = nil if post_count_between[1] < 0
created_between = params[:created_from].presence, params[:created_to].presence created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_to].presence updated_between = params[:updated_from].presence, params[:updated_to].presence
deprecated_given = params.key?(:deprecated)
deprecated = bool?(:deprecated)
order = params[:order].to_s.split(':', 2).map(&:strip) order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at']) unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
@@ -44,6 +51,9 @@ class TagsController < ApplicationController
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
if deprecated_given
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
end
sort_sql = sort_sql =
case order[0] case order[0]
@@ -72,39 +82,20 @@ class TagsController < ApplicationController
def with_depth def with_depth
parent_tag_id = params[:parent].to_i parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0 parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any')
graph = build_with_depth_graph(material_filter)
tag_ids = tag_ids =
if parent_tag_id if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id) visible_child_tag_ids(parent_tag_id, graph)
else else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id) visible_root_tag_ids(graph)
end end
tags = render json: tag_ids
Tag .sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
.joins(:tag_name) .map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(category: [:meme, :character, :material])
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a
has_children_tag_ids =
if tags.empty?
[]
else
TagImplication
.joins(:tag)
.where(parent_tag_id: tags.map(&:id),
tags: { category: [:meme, :character, :material] })
.distinct
.pluck(:parent_tag_id)
end
render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end end
def autocomplete def autocomplete
@@ -129,6 +120,7 @@ class TagsController < ApplicationController
base = Tag.joins(:tag_name) base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
canonical_hit = canonical_hit =
@@ -164,7 +156,7 @@ class TagsController < ApplicationController
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return head :bad_request if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -182,31 +174,62 @@ 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
name = params[:name].to_s.strip name = params[:name].to_s.strip
return head :bad_request if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
.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.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return head :bad_request if name.blank? return render_bad_request('name は必須です.') if name.blank?
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag
render json: build_tag_children(tag) graph = build_with_depth_graph(material_filter)
render json: build_tag_children(tag, graph:)
end end
def update_all def update_all
@@ -218,38 +241,52 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip name = params[:name].to_s.strip
category = params[:category].to_s.strip category = params[:category].to_s.strip
return head :unprocessable_entity if name.blank? || category.blank? return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
if name != tag.name && return unless validate_tag_rename(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 alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated)
if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name old_name = tag.name
name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.update!(category:) if tag.deprecated? == deprecated
tag.tag_name.update!(name:) tag.update!(category:)
else
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
end
rename_tag_name!(tag, name) if name_changed
alias_names << old_name if name != old_name alias_names << old_name if name_changed
alias_names.delete(name) alias_names.delete(name)
update_aliases!(tag, alias_names) update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names) update_parent_tags!(tag, parent_names)
tag.reload tag.reload
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload) render json: TagRepr.base(tag.reload)
@@ -261,21 +298,42 @@ class TagsController < ApplicationController
name = params[:name].presence name = params[:name].presence
category = params[:category].presence category = params[:category].presence
deprecated_given = params.key?(:deprecated)
deprecated = bool?(:deprecated)
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico') if tag.nico? || (category.present? && category == 'nico')
return render json: { error: 'ニコタグは変更できません.' }, return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
status: :unprocessable_entity
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
tag.tag_name.update!(name:) if name.present? old_name = tag.name
tag.update!(category:) if category.present? name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
record_tag_version!(tag, event_type: :update, created_by_user: current_user) rename_tag_name!(tag, name) if name_changed
tag.update!(category:) if category.present?
if deprecated_given && tag.deprecated? != deprecated
tag.update!(deprecated_at: deprecated ? Time.current : nil)
end
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.reload) render json: TagRepr.base(tag.reload)
@@ -283,31 +341,282 @@ class TagsController < ApplicationController
private private
def build_tag_children tag def material_filter_param default:
value = params[:material_filter].to_s.presence
return default unless ['present', 'missing', 'any'].include?(value)
value
end
def build_with_depth_graph material_filter
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
children_by_parent_id[parent_id] << child_id
parent_ids_by_child_id[child_id] << parent_id
end
tag_ids = (children_by_parent_id.keys +
parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
tags_by_id = Tag.joins(:tag_name)
.where(id: tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
has_material: material_tag_ids.include?(id) }
end
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
visible_child_tag_ids_by_parent_id: { },
visible_subtree_by_tag_id: { },
material_filter: }
end
def visible_root_tag_ids graph
root_ids = Set.new
graph[:tags_by_id].each do |tag_id, attrs|
next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph)
if attrs[:deprecated]
collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new)
else
root_ids << tag_id
end
end
root_ids.to_a
end
def visible_root_tag? tag_id, graph
seen = Set.new([tag_id])
stack = graph[:parent_ids_by_child_id][tag_id].dup
until stack.empty?
parent_id = stack.pop
next if seen.include?(parent_id)
seen << parent_id
parent = graph[:tags_by_id][parent_id]
next unless parent
return false unless parent[:deprecated]
stack.concat(graph[:parent_ids_by_child_id][parent_id])
end
true
end
def visible_child_tag_ids parent_tag_id, graph
cache = graph[:visible_child_tag_ids_by_parent_id]
return cache[parent_tag_id] if cache.key?(parent_tag_id)
visible_ids = Set.new
graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id]))
end
cache[parent_tag_id] = visible_ids.to_a
end
def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen
return if seen.include?(tag_id)
seen = seen.dup << tag_id
tag = graph[:tags_by_id][tag_id]
return unless tag
if tag[:deprecated]
graph[:children_by_parent_id][tag_id].each do |child_tag_id|
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen)
end
return
end
if with_depth_visible_tag?(tag, graph[:material_filter])
visible_ids << tag_id
return
end
visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
def with_depth_visible_tag? tag, material_filter
return false unless tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
case material_filter
when 'present'
tag[:has_material]
when 'missing'
!tag[:has_material]
else
true
end
end
def visible_subtree? tag_id, graph
cache = graph[:visible_subtree_by_tag_id]
return cache[tag_id] if cache.key?(tag_id)
tag = graph[:tags_by_id][tag_id]
return cache[tag_id] = false unless tag
if tag[:deprecated]
return cache[tag_id] =
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
cache[tag_id] =
with_depth_visible_tag?(tag, graph[:material_filter]) ||
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
visible_subtree?(child_tag_id, graph)
}
end
def with_depth_lightweight_repr tag_id, graph
tag = graph[:tags_by_id][tag_id]
{ id: tag_id,
name: tag[:name],
category: tag[:category],
deprecated: tag[:deprecated],
has_material: tag[:has_material],
has_children: visible_child_tag_ids(tag_id, graph).present?,
children: [] }
end
def build_tag_children tag, graph: nil
material = tag.materials.first material = tag.materials.first
file = nil tag_graph = graph && graph[:tags_by_id][tag.id]
content_type = nil material =
if material&.file&.attached? nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type children = tag.children.sort_by(&:name)
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end end
TagRepr.base(tag).merge( TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, children: children.map { build_tag_children(_1, graph:) },
material: material.as_json&.merge(file:, content_type:)) has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
end end
def record_tag_version! tag, event_type:, created_by_user: def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico? if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else return
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end 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 validate_tag_rename tag, name
return true if name.blank? || name == tag.name
if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
return false
end
target_tag_name = TagName.with_discarded.find_by(name:)
return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
false
end
def rename_tag_name! tag, name
return if name == tag.name
current_tag_name = tag.tag_name
target_tag_name = TagName.with_discarded.find_by(name:)
if target_tag_name.nil?
current_tag_name.update!(name:)
return
end
promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
old_owner_tag = promoted_tag_name.canonical&.tag
if old_owner_tag && old_owner_tag != tag
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil)
TagName.with_discarded
.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name)
end
current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
tag.update!(tag_name: promoted_tag_name)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical: promoted_tag_name)
return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user)
end end
def update_aliases! tag, alias_names def update_aliases! tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a 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| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
@@ -318,12 +627,16 @@ class TagsController < ApplicationController
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
end
end end
def update_parent_tags! tag, parent_names def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false, with_no_deerjikist: false,
deny_nico: true) deny_nico: true)
old_parent_tags = tag.parents.to_a old_parent_tags = tag.parents.to_a
@@ -338,4 +651,40 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
end end
end end
def normalise_deerjikist_code platform, code
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
html = Net::HTTP.get(URI(url))
canonical = html[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical
html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end end
+25 -3
ファイルの表示
@@ -1,21 +1,28 @@
class TheatreCommentsController < ApplicationController class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt.negative? no_gt = 0 if no_gt < 0
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.order(no: :desc) .order(no: :desc)
.limit(limit)
render json: comments.as_json(include: { user: { only: [:id, :name] } }) render json: comments.map {
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
content = params[:content] content = params[:content]
return head :unprocessable_entity if content.blank? return render_unprocessable_entity('本文は必須です.', field: :content) if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id]) theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre return head :not_found unless theatre
@@ -29,4 +36,19 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created render json: comment, status: :created
end end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
return head :forbidden unless comment.user == current_user
comment.discard!
head :no_content
end
end end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class TheatreProgrammesController < ApplicationController
def index
limit = params[:limit].to_i
limit = 100 if limit <= 0
position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(:post)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.map { |programme|
programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
}
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+113 -8
ファイルの表示
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: { render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:)
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end end
def next_post def next_post
@@ -43,12 +41,119 @@ class TheatresController < ApplicationController
return head :not_found unless theatre return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
post = Post.where("url LIKE '%nicovideo.jp%'") ApplicationRecord.transaction do
.or(Post.where("url LIKE '%youtube.com%'")) theatre.lock!
.order('RAND()') TheatrePostAdvancer.call(theatre:)
.first end
theatre.update!(current_post: post, current_post_started_at: Time.current)
head :no_content head :no_content
end end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
if theatre.current_post_id != requested_post_id
conflicted = true
next
end
TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
theatre.with_lock do
if theatre.current_post
if theatre.current_post_id != requested_post_id
conflicted = true
else
TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end end
+61
ファイルの表示
@@ -0,0 +1,61 @@
class UserSettingsController < ApplicationController
wrap_parameters false
def show
return head :unauthorized unless current_user
render json: current_setting.serializable_hash
end
def update
return head :unauthorized unless current_user
raw_attributes = editable_raw_attributes
field_errors = validate_raw_attributes(raw_attributes)
return render_validation_error fields: field_errors if field_errors.present?
setting = current_setting
setting.assign_attributes(raw_attributes)
if setting.save
render json: setting.serializable_hash, status: :ok
else
render_validation_error setting
end
end
private
def current_setting
Setting.find_or_create_by!(user: current_user) do |setting|
setting.assign_attributes(Setting.defaults)
end
rescue ActiveRecord::RecordNotUnique
Setting.find_by!(user: current_user)
end
def editable_raw_attributes
request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES)
end
def validate_raw_attributes raw_attributes
raw_attributes.each_with_object({ }) do |(key, value), errors|
next if value_matches_type?(key, value)
errors[key.to_sym] = ['値の型が不正です.']
end
end
def value_matches_type? key, value
case Setting::TYPE_BY_ATTRIBUTE.fetch(key)
when :string
value.is_a?(String)
when :integer
value.is_a?(Integer)
when :boolean
value == true || value == false
else
false
end
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
class UserThemeSlotsController < ApplicationController
wrap_parameters false
def index
return head :unauthorized unless current_user
render json: current_user
.theme_slots
.order(:base_theme, :slot_no)
.map { |slot| slot.serializable_hash }
end
def update
return head :unauthorized unless current_user
base_theme = params[:base_theme].to_s
slot_no = params[:slot_no].to_i
tokens = params[:tokens]
unless UserThemeSlot::BASE_THEMES.include?(base_theme)
return render_validation_error fields: { base_theme: ['値が不正です.'] }
end
unless UserThemeSlot::SLOT_NOS.include?(slot_no)
return render_validation_error fields: { slot_no: ['値が不正です.'] }
end
unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash)
return render_validation_error fields: { tokens: ['JSON object で指定してください.'] }
end
slot = UserThemeSlot.find_or_initialize_by(user: current_user,
base_theme:,
slot_no:)
slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens
slot.save!
render json: slot.serializable_hash, status: :ok
end
end
+22 -9
ファイルの表示
@@ -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
@@ -38,12 +42,21 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i return head :unauthorized if user&.id != params[:id].to_i
name = params[:name] name = params[:name]
return head :bad_request if name.blank? return render_unprocessable_entity('名前は必須です.', field: :name) 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_validation_error user
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
+57 -32
ファイルの表示
@@ -4,17 +4,18 @@ class WikiPagesController < ApplicationController
def index def index
title = params[:title].to_s.strip title = params[:title].to_s.strip
if title.blank? if title.blank?
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name)) return render json: WikiPageRepr.base(
WikiPage.joins(:tag_name).includes(tag_name: :tag))
end end
q = WikiPage.joins(:tag_name).includes(:tag_name) q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: WikiPageRepr.base(q.limit(20)) render json: WikiPageRepr.base(q.limit(20))
end end
def show def show
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(:tag_name) .includes(tag_name: :tag)
.find_by(id: params[:id]) .find_by(id: params[:id])
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
@@ -22,7 +23,7 @@ class WikiPagesController < ApplicationController
def show_by_title def show_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(:tag_name) .includes(tag_name: :tag)
.find_by(tag_name: { name: title }) .find_by(tag_name: { name: title })
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
@@ -46,17 +47,17 @@ class WikiPagesController < ApplicationController
def diff def diff
id = params[:id] id = params[:id]
return head :bad_request if id.blank? return render_bad_request('id は必須です.') if id.blank?
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id) page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
from_rev = from && page.wiki_revisions.find(from) from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return head :unprocessable_entity return render_unprocessable_entity('差分を表示できない版です.')
end end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -76,6 +77,7 @@ class WikiPagesController < ApplicationController
render json: { wiki_page_id: page.id, render json: { wiki_page_id: page.id,
title: page.title, title: page.title,
deprecated_at: page.deprecated_at,
older_revision_id: from_rev&.id, older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id, newer_revision_id: to_rev.id,
diff: diff_json } diff: diff_json }
@@ -85,22 +87,27 @@ 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
message = params[:message].presence
return head :unprocessable_entity if name.blank? || body.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name:) tag_name = TagName.find_undiscard_or_create_by!(name: title)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
render json: WikiPageRepr.base(page), status: :created page =
else Wiki::Commit.create_content!(
render json: { errors: page.errors.full_messages }, tag_name:,
status: :unprocessable_entity body:,
end created_by_user: current_user,
message:)
render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end end
def update def update
@@ -110,22 +117,38 @@ class WikiPagesController < ApplicationController
title = params[:title]&.strip title = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
return head :unprocessable_entity if title.blank? || body.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if 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
page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
if tag && title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
end end
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
head :ok head :ok
end end
@@ -136,7 +159,7 @@ class WikiPagesController < ApplicationController
def changes def changes
id = params[:id].presence id = params[:id].presence
q = WikiRevision.joins(wiki_page: :tag_name) q = WikiRevision.joins(wiki_page: :tag_name)
.includes(:created_user, wiki_page: :tag_name) .includes(:created_user, wiki_page: { tag_name: :tag })
.order(id: :desc) .order(id: :desc)
q = q.where(wiki_page_id: id) if id q = q.where(wiki_page_id: id) if id
@@ -144,7 +167,9 @@ class WikiPagesController < ApplicationController
{ revision_id: rev.id, { revision_id: rev.id,
pred: rev.base_revision_id, pred: rev.base_revision_id,
succ: nil, succ: nil,
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind, kind: rev.kind,
message: rev.message, message: rev.message,
+21
ファイルの表示
@@ -0,0 +1,21 @@
class GekanatorAiRun < ApplicationRecord
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
belongs_to :gekanator_question_suggestion
validates :model, presence: true, length: { maximum: 255 }
validates :status, presence: true, inclusion: { in: STATUSES }
validates :input_tokens,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :output_tokens,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :estimated_cost_jpy,
presence: true,
numericality: { greater_than_or_equal_to: 0 }
scope :this_month, lambda {
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
}
end
+15
ファイルの表示
@@ -0,0 +1,15 @@
class GekanatorGame < ApplicationRecord
belongs_to :user
belongs_to :guessed_post, class_name: 'Post'
belongs_to :correct_post, class_name: 'Post'
has_many :question_suggestions,
class_name: 'GekanatorQuestionSuggestion',
dependent: :delete_all
has_many :question_examples,
class_name: 'GekanatorQuestionExample',
dependent: :delete_all
validates :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
validates :won, inclusion: { in: [true, false] }
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
class GekanatorQuestion < ApplicationRecord
KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
belongs_to :gekanator_question_suggestion, optional: true
belongs_to :created_by, class_name: 'User', optional: true
has_many :gekanator_question_examples, dependent: :delete_all
validates :kind, presence: true, inclusion: { in: KINDS }
validates :source, presence: true, inclusion: { in: SOURCES }
validates :status, presence: true, inclusion: { in: STATUSES }
validates :text, presence: true, length: { maximum: 1000 }
validates :condition, presence: true
validates :priority_weight,
presence: true,
numericality: {
greater_than: 0,
less_than_or_equal_to: 3
}
scope :accepted, -> { where(status: 'accepted') }
end
+97
ファイルの表示
@@ -0,0 +1,97 @@
class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_answer', 'post_game_extra'].freeze
belongs_to :gekanator_question
belongs_to :post
belongs_to :user
belongs_to :gekanator_game, optional: true
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :answer_counts, presence: true
validates :sample_count,
presence: true,
numericality: {
only_integer: true,
greater_than: 0
}
validates :source, presence: true, inclusion: { in: SOURCES }
validates :weight,
presence: true,
numericality: {
greater_than: 0
}
before_validation :normalize_learning_state
def record_answer!(answer:, source:, gekanator_game: nil)
answer = answer.to_s
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
counts = normalized_answer_counts
counts[answer] += 1
self.answer_counts = counts
self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source
apply_aggregated_answer!(preferred_answer: answer)
self
end
private
def normalize_learning_state
counts = normalized_answer_counts
if counts.values.sum.zero? && answer.present?
counts[answer] = 1
end
self.answer_counts = counts
self.sample_count = counts.values.sum
apply_aggregated_answer!
end
def apply_aggregated_answer!(preferred_answer: nil)
counts = normalized_answer_counts
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
known_total = known_counts.values.sum
if known_total.zero?
self.answer = 'unknown'
self.weight = 0.1
return
else
max_count = known_counts.values.max
candidates = known_counts.select { |_answer, count| count == max_count }.keys
self.answer =
if preferred_answer.present? && candidates.include?(preferred_answer)
preferred_answer
elsif answer.present? && candidates.include?(answer)
answer
else
candidates.first
end
end
consensus = max_count.to_f / known_total
self.weight = Math.sqrt(known_total) * consensus
end
def normalized_answer_counts
base = ANSWERS.index_with(0)
answer_counts.to_h.each do |key, value|
answer_key = key.to_s
next unless ANSWERS.include?(answer_key)
base[answer_key] = value.to_i
end
base
end
end
+12
ファイルの表示
@@ -0,0 +1,12 @@
class GekanatorQuestionSuggestion < ApplicationRecord
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game
belongs_to :user
has_many :gekanator_questions, dependent: :nullify
has_many :gekanator_ai_runs, dependent: :destroy
validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] }
end
+6 -2
ファイルの表示
@@ -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
+43 -2
ファイルの表示
@@ -1,6 +1,9 @@
class Material < ApplicationRecord class Material < ApplicationRecord
include MyDiscard include MyDiscard
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
'legacy_drive_path'].freeze
default_scope -> { kept } default_scope -> { kept }
belongs_to :parent, class_name: 'Material', optional: true belongs_to :parent, class_name: 'Material', optional: true
@@ -10,9 +13,18 @@ class Material < ApplicationRecord
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
has_one_attached :file, dependent: :purge has_many :material_versions, dependent: :destroy
has_many :material_export_items, dependent: :destroy
validates :tag_id, presence: true, uniqueness: true has_one_attached :file, dependent: :purge
has_one_attached :thumbnail, dependent: :purge
before_validation :assign_normalized_source_key
validates :tag_id, uniqueness: true, allow_nil: true
validates :source_kind,
inclusion: { in: SOURCE_KINDS },
allow_blank: true
validate :file_must_be_attached validate :file_must_be_attached
validate :tag_must_be_material_category validate :tag_must_be_material_category
@@ -23,8 +35,37 @@ class Material < ApplicationRecord
file.blob.content_type file.blob.content_type
end end
def file_byte_size
return nil unless file&.attached?
file.blob.byte_size
end
def file_filename
return nil unless file&.attached?
file.blob.filename.to_s
end
def snapshot_export_paths
material_export_items.order(:profile).pluck(:profile, :export_path).to_h
end
private private
def assign_normalized_source_key
if source_kind.blank?
self.normalized_source_key = nil
return
end
self.normalized_source_key =
MaterialSyncSuppression.normalize_source_key(source_kind:,
source_uri:,
drive_path: source_path,
drive_file_id: source_file_id)
end
def file_must_be_attached def file_must_be_attached
return if url.present? || file.attached? return if url.present? || file.attached?
+48
ファイルの表示
@@ -0,0 +1,48 @@
class MaterialExportItem < ApplicationRecord
VALID_PROFILES = ['legacy_drive'].freeze
belongs_to :material
belongs_to :created_by_user, class_name: 'User', optional: true
validates :profile, presence: true, inclusion: { in: VALID_PROFILES }
validates :export_path, presence: true, uniqueness: { scope: :profile }
validates :material_id, uniqueness: { scope: :profile }
validate :export_path_must_be_relative_safe_path
scope :enabled, -> { where(enabled: true) }
private
def export_path_must_be_relative_safe_path
return if export_path.blank?
if export_path.start_with?('/')
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.match?(/\A[A-Za-z]:\//)
errors.add(:export_path, '絶対パスは使へません.')
end
if export_path.include?('\\')
errors.add(:export_path, '/ 区切りで指定してください.')
end
if export_path.include?("\0")
errors.add(:export_path, 'NUL は使へません.')
end
parts = export_path.split('/')
if export_path.include?('//')
errors.add(:export_path, '空の path segment は使へません.')
end
if parts.any? { |part| part.in?(['.', '..']) }
errors.add(:export_path, '.. は使へません.')
end
if export_path.end_with?('/')
errors.add(:export_path, 'directory path は使へません.')
end
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class MaterialImportBlock < ApplicationRecord
MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze
REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'
].freeze
belongs_to :created_by_user, class_name: 'User', optional: true
validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS }
validates :reason, presence: true, inclusion: { in: REASONS }
validates :sha256, length: { is: 64 }, allow_blank: true
validate :match_value_must_be_present
private
def match_value_must_be_present
return if match_kind == 'manual'
return if sha256.present? || external_path_pattern.present?
errors.add(:base, 'sha256 または external_path_pattern は必須です.')
end
end
+45
ファイルの表示
@@ -0,0 +1,45 @@
class MaterialSyncSource < ApplicationRecord
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file', 'legacy_drive_path'].freeze
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
validates :name, presence: true
validates :source_kind,
presence: true,
inclusion: { in: SOURCE_KINDS }
validates :profile, presence: true, inclusion: { in: MaterialExportItem::VALID_PROFILES }
validate :source_value_must_be_present
scope :enabled, -> { where(enabled: true) }
def normalized_source_key
MaterialSyncSuppression.normalize_source_key(source_kind:,
source_uri:,
source_path:,
source_file_id:)
end
private
def source_value_must_be_present
return if source_value_present?
errors.add(:base, '同期元を指定してください.')
end
def source_value_present?
case source_kind
when 'uri'
source_uri.present?
when 'google_drive_file'
source_file_id.present? || source_uri.present?
when 'google_drive_path'
source_file_id.present? || source_uri.present?
when 'legacy_drive_path'
source_path.present?
else
normalized_source_key.present?
end
end
end
+105
ファイルの表示
@@ -0,0 +1,105 @@
class MaterialSyncSuppression < ApplicationRecord
SOURCE_KINDS = [
'uri',
'google_drive_path',
'google_drive_path_prefix',
'google_drive_file',
'legacy_drive_path',
'legacy_drive_path_prefix'
].freeze
REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'
].freeze
belongs_to :created_by_user, class_name: 'User', optional: true
before_validation :assign_normalized_source_key
validates :source_kind, presence: true, inclusion: { in: SOURCE_KINDS }
validates :reason, presence: true, inclusion: { in: REASONS }
validates :normalized_source_key, presence: true, uniqueness: true
validate :source_value_must_be_present
validate :drive_path_must_be_safe_relative_path
def self.normalize_source_key source_kind:,
source_uri: nil,
drive_path: nil,
drive_file_id: nil,
source_path: nil,
source_file_id: nil
kind = source_kind.to_s
value =
case kind
when 'uri'
source_uri.to_s.strip
when 'google_drive_path', 'google_drive_path_prefix',
'legacy_drive_path', 'legacy_drive_path_prefix'
(source_path || drive_path).to_s.strip.gsub(%r{/+}, '/')
when 'google_drive_file'
(source_file_id || drive_file_id).to_s.strip
else
''
end
return nil if kind.blank? || value.blank?
"#{ kind }:#{ value }"
end
private
def assign_normalized_source_key
normalize_path_fields!
self.normalized_source_key =
self.class.normalize_source_key(source_kind:,
source_uri:,
drive_path:,
drive_file_id:)
end
def source_value_must_be_present
return if normalized_source_key.present?
errors.add(:base, '同期元を指定してください.')
end
def drive_path_must_be_safe_relative_path
return unless source_kind.in?(
['google_drive_path', 'google_drive_path_prefix',
'legacy_drive_path', 'legacy_drive_path_prefix'])
value = drive_path.to_s
return if value.blank?
if value.start_with?('/') || value.match?(/\A[A-Za-z]:\//)
errors.add(:drive_path, '相対 path を指定してください.')
end
if value.start_with?('My Drive/', 'マイドライブ/')
errors.add(:drive_path, '同期元フォルダからの相対 path を指定してください.')
end
errors.add(:drive_path, 'NUL は使へません.') if value.include?("\0")
errors.add(:drive_path, '連続スラッシュは使へません.') if value.include?('//')
errors.add(:drive_path, '末尾スラッシュは使へません.') if value.end_with?('/')
parts = value.split('/')
if parts.any? { |part| part.in?(['.', '..']) }
errors.add(:drive_path, '. や .. は使へません.')
end
end
def normalize_path_fields!
return unless source_kind.in?(
['google_drive_path', 'google_drive_path_prefix',
'legacy_drive_path', 'legacy_drive_path_prefix'])
self.drive_path = drive_path.to_s.tr('\\', '/').strip.presence
end
end
+17
ファイルの表示
@@ -0,0 +1,17 @@
class MaterialVersion < ApplicationRecord
EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
include VersionRecord
belongs_to :material
belongs_to :tag, optional: true
belongs_to :parent, class_name: 'Material', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
def export_paths_hash
export_paths_json || {}
end
end
+101 -16
ファイルの表示
@@ -1,37 +1,127 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
require 'stringio'
def self.resized_thumbnail_attachment(upload)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image.resize '180x180'
image.format 'jpg'
{ io: StringIO.new(image.to_blob),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
ensure
upload.rewind
end
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
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags has_many :tags, through: :active_post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :active_post_tags, source: :tag
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
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
before_validation :normalise_url attribute :version_no, :integer, default: 1
validates :url, presence: true, uniqueness: true before_validation :normalise_url, if: :will_save_change_to_url?
validates :url, presence: true, uniqueness: true, length: { maximum: 768 }
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
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
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
remainder_ms = ms % 1_000
base =
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end
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)
@@ -47,12 +137,7 @@ class Post < ApplicationRecord
def resized_thumbnail! def resized_thumbnail!
return unless thumbnail.attached? return unless thumbnail.attached?
image = MiniMagick::Image.read(thumbnail.download) thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
image.resize '180x180'
thumbnail.purge
thumbnail.attach(io: File.open(image.path),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
end end
private private
@@ -67,7 +152,7 @@ class Post < ApplicationRecord
return if !(f) || !(b) return if !(f) || !(b)
if f >= b if f >= b
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
end end
end end
@@ -95,7 +180,7 @@ class Post < ApplicationRecord
u.host = u.host.downcase if u.host u.host = u.host.downcase if u.host
u.path = u.path.sub(/\/\Z/, '') if u.path.present? u.path = u.path.sub(/\/\Z/, '') if u.path.present?
self.url = u.to_s self.url = PostUrlSanitisationRule.sanitise(u.to_s)
rescue URI::InvalidURIError rescue URI::InvalidURIError
; ;
end end
+19
ファイルの表示
@@ -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
+6
ファイルの表示
@@ -10,6 +10,12 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { validates :post_id, uniqueness: {
+20
ファイルの表示
@@ -0,0 +1,20 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms },
allow_nil: true
end
+163
ファイルの表示
@@ -0,0 +1,163 @@
class PostUrlSanitisationRule < ApplicationRecord
include Discard::Model
class InvalidUrlError < StandardError
attr_reader :invalid_rows
def initialize(invalid_rows)
@invalid_rows = invalid_rows
ids = invalid_rows.map { _1.fetch(:post_id) }.join(', ')
super("post URL sanitisation produced invalid URLs for posts #{ ids }")
end
end
class UrlConflictError < StandardError
attr_reader :conflicts
def initialize(conflicts)
@conflicts = conflicts
urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ')
super("post URL sanitisation conflicts detected for #{ urls }")
end
end
self.primary_key = :priority
default_scope -> { kept }
validates :source_pattern, presence: true, uniqueness: true
validate :source_pattern_must_be_regexp
class << self
def sanitise(url) = sanitise_with_rules(url, rules)
def apply!
rewrites = nil
Post.transaction do
compiled_rules = rules
rewrites = Post.order(:id)
.lock('FOR UPDATE')
.pluck(:id, :url)
.map do |post_id, original_url|
{ post_id:,
original_url:,
sanitised_url: sanitise_with_rules(original_url, compiled_rules) }
end
invalid_rows = rewrites.filter { invalid_sanitised_url?(_1.fetch(:sanitised_url)) }
.map { { post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url),
sanitised_url: _1.fetch(:sanitised_url) } }
raise InvalidUrlError.new(invalid_rows) if invalid_rows.present?
conflicts = build_conflicts(rewrites)
raise UrlConflictError.new(conflicts) if conflicts.present?
changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
return if changed.empty?
token = SecureRandom.hex(6)
changed.each do |row|
Post.where(id: row.fetch(:post_id))
.update_all(url: temporary_url_for(row.fetch(:post_id), token))
end
changed.each do |row|
Post.where(id: row.fetch(:post_id))
.update_all(url: row.fetch(:sanitised_url))
end
end
rescue ActiveRecord::RecordNotUnique => error
conflicts = build_persisted_conflicts(rewrites)
raise error if conflicts.empty?
raise UrlConflictError.new(conflicts), cause: error
end
private
def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] }
def sanitise_with_rules(url, compiled_rules)
compiled_rules.reduce(url.dup) do |value, (pattern, replacement)|
value.sub(pattern, replacement)
end
end
def temporary_url_for(post_id, token) =
"https://post-url-sanitising.invalid/#{ token }/#{ post_id }"
def invalid_sanitised_url?(url)
return true if url.blank?
return true if url.length > 768
parsed = URI.parse(url)
return true if !(parsed in URI::HTTP)
return true if parsed.host.blank?
false
rescue URI::InvalidURIError
true
end
def build_conflicts(rewrites)
rewrites
.group_by { _1.fetch(:sanitised_url).downcase }
.values
.filter { _1.size > 1 }
.flatten
.map { { url: _1.fetch(:sanitised_url),
post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url) } }
end
def build_persisted_conflicts(rewrites)
return [] if rewrites.blank?
target_rows = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
target_keys = target_rows.map { _1.fetch(:sanitised_url).downcase }.uniq
return [] if target_keys.empty?
target_pairs = target_rows.to_h do |row|
[row.fetch(:post_id), row.fetch(:sanitised_url).downcase]
end
persisted_rows = Post.order(:id)
.where('LOWER(url) IN (?)', target_keys)
.pluck(:id, :url)
.reject { |post_id, original_url| target_pairs[post_id] == original_url.downcase }
.map { |post_id, original_url|
{ url: original_url,
post_id:,
original_url:,
conflict_key: original_url.downcase }
}
target_conflicts = target_rows.map { { url: _1.fetch(:sanitised_url),
post_id: _1.fetch(:post_id),
original_url: _1.fetch(:original_url),
conflict_key: _1.fetch(:sanitised_url).downcase } }
(persisted_rows + target_conflicts)
.group_by { _1.fetch(:conflict_key) }
.values
.filter { _1.size > 1 }
.flatten
.map { _1.except(:conflict_key) }
end
end
private
def source_pattern_must_be_regexp
return if source_pattern.blank?
Regexp.new(source_pattern)
rescue RegexpError
errors.add :source_pattern, '変な正規表現だね〜(笑)'
end
end
+1
ファイルの表示
@@ -5,6 +5,7 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range validate :validate_original_created_range
+47 -2
ファイルの表示
@@ -1,7 +1,52 @@
class Setting < ApplicationRecord class Setting < ApplicationRecord
THEMES = ['system', 'light', 'dark'].freeze
# These remain in the typed settings schema to preserve existing backend
# work. The common `/users/settings` page currently surfaces only `theme`.
AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
STRING_ATTRIBUTES = [
'theme',
'auto_fetch_title',
'auto_fetch_thumbnail',
'wiki_editor_mode',
].freeze
INTEGER_ATTRIBUTES = [].freeze
BOOLEAN_ATTRIBUTES = [].freeze
EDITABLE_ATTRIBUTES =
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
TYPE_BY_ATTRIBUTE = {
'theme' => :string,
'auto_fetch_title' => :string,
'auto_fetch_thumbnail' => :string,
'wiki_editor_mode' => :string,
}.freeze
belongs_to :user belongs_to :user
validates :user_id, presence: true validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 } validates :user_id, uniqueness: true
validates :value, presence: true
validates :theme, inclusion: { in: THEMES }
validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES }
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
def self.defaults
{
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split',
}
end
def self.serializable_attributes
EDITABLE_ATTRIBUTES.map(&:to_sym)
end
def serializable_hash(options = nil)
super({ only: self.class.serializable_attributes }.merge(options || { }))
end
end end
+142 -5
ファイルの表示
@@ -8,6 +8,25 @@ class Tag < ApplicationRecord
; ;
end end
class DeprecatedTagNormalisationError < ArgumentError
attr_reader :tag_names
def initialize tag_names
@tag_names = Array(tag_names)
super('deprecated tags are not allowed')
end
end
class SectionLiteralParseError < ArgumentError
attr_reader :tag_name, :literal
def initialize tag_name, literal
@tag_name = tag_name
@literal = literal
super("invalid section literal for tag #{ tag_name }: #{ literal }")
end
end
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -40,6 +59,8 @@ class Tag < ApplicationRecord
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
@@ -56,6 +77,7 @@ class Tag < ApplicationRecord
validate :nico_tag_name_must_start_with_nico validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico } scope :nico_tags, -> { nico }
@@ -75,34 +97,78 @@ class Tag < ApplicationRecord
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
end end
def deprecated? = deprecated_at?
def has_wiki = wiki_page.present? def has_wiki = wiki_page.present?
def material_id = materials.first&.id def material_id = materials.first&.id
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta) def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
def self.normalise_tags tag_names, with_tagme: true, def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: true deny_nico: true,
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
sections = { }
tags = tag_names.map do |name| tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name]
end
tag.update!(category: cat) if cat && tag.category != cat tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end end
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 with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id) tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end end
def self.expand_parent_tags tags def self.expand_parent_tags tags
@@ -128,6 +194,45 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id } (result + tags).uniq { |t| t.id }
end end
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def self.merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def self.merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?
@@ -223,4 +328,36 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end end
end end
def self.time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end end
+24
ファイルの表示
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
validates :parent_tag_id, presence: true validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself validate :parent_tag_mustnt_be_itself
validate :parent_tag_mustnt_create_cycle
private private
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
end end
end end
def parent_tag_mustnt_create_cycle
return if tag_id.blank? || parent_tag_id.blank?
return if errors[:parent_tag_id].present?
seen = { }
stack = [parent_tag_id]
until stack.empty?
current_id = stack.pop
next if seen[current_id]
seen[current_id] = true
if current_id == tag_id
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
errors.add :base, 'タグの親子関係が循環します.'
return
end
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
end
end
end end
+4
ファイルの表示
@@ -7,6 +7,10 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User' belongs_to :created_by_user, class_name: 'User'
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
+10
ファイルの表示
@@ -0,0 +1,10 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+7 -2
ファイルの表示
@@ -4,11 +4,11 @@ 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
has_many :settings has_one :setting, dependent: :destroy
has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
@@ -19,5 +19,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
+27
ファイルの表示
@@ -0,0 +1,27 @@
class UserThemeSlot < ApplicationRecord
BASE_THEMES = ['light', 'dark'].freeze
SLOT_NOS = [1, 2, 3].freeze
belongs_to :user
validates :user_id, presence: true
validates :base_theme, presence: true, inclusion: { in: BASE_THEMES }
validates :slot_no, presence: true, inclusion: { in: SLOT_NOS }
validates :tokens, presence: true
validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] }
validate :tokens_must_be_object
def serializable_hash(options = nil)
hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] }
super(hash.merge(options || {}))
end
private
def tokens_must_be_object
return if tokens.is_a?(Hash)
errors.add(:tokens, 'は JSON object で指定してください.')
end
end
+12 -4
ファイルの表示
@@ -1,15 +1,23 @@
module VersionRecord module VersionRecord
extend ActiveSupport::Concern extend ActiveSupport::Concern
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
def readonly? = persisted? def readonly? = persisted?
included do included do
event_type_map = if const_defined?(:EVENT_TYPE_MAP, false)
const_get(:EVENT_TYPE_MAP)
else
DEFAULT_EVENT_TYPE_MAP
end
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create', enum :event_type, event_type_map, prefix: true, validate: true
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true validates :event_type, presence: true
+6 -5
ファイルの表示
@@ -13,10 +13,16 @@ 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
def deprecated_at = tag_name.tag&.deprecated_at
def title= val def title= val
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
@@ -24,11 +30,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
ファイルの表示
@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+87 -2
ファイルの表示
@@ -2,7 +2,9 @@
module MaterialRepr module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at], BASE = { only: [:id, :url, :version_no, :source_kind, :source_uri,
:source_path, :source_file_id, :normalized_source_key,
:created_at, :updated_at],
methods: [:content_type], methods: [:content_type],
include: { tag: TagRepr::BASE, include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE, created_by_user: UserRepr::BASE,
@@ -15,10 +17,93 @@ module MaterialRepr
file: if material.file.attached? file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end) end,
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
file_byte_size: material.file_byte_size,
export_paths: export_paths(material),
export_items: export_items(material))
end end
def many materials, host: def many materials, host:
materials.map { |m| base(m, host:) } materials.map { |m| base(m, host:) }
end end
def list material, host:
{ id: material.id,
version_no: material.version_no,
url: material.url,
source_kind: material.source_kind,
source_uri: material.source_uri,
source_path: material.source_path,
source_file_id: material.source_file_id,
normalized_source_key: material.normalized_source_key,
tag: compact_tag(material.tag),
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
content_type: material.content_type,
file_byte_size: material.file_byte_size,
created_at: material.created_at,
updated_at: material.updated_at,
export_paths: export_paths(material),
export_items: export_items(material) }
end
def list_many materials, host:
materials.map { |m| list(m, host:) }
end
def export_paths material
material.material_export_items.each_with_object({ }) do |item, hash|
hash[item.profile] = item.enabled ? item.export_path : ''
end
end
def export_items material
material.material_export_items.map do |item|
{ id: item.id,
profile: item.profile,
export_path: item.export_path,
enabled: item.enabled }
end
end
def thumbnail_url material, host:
return nil unless material.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.thumbnail, host:)
end
def thumbnail_fallback_text material
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
end
def thumbnail_fallback_kind material
material.tag.present? ? 'tag_name' : 'created_at'
end
def media_kind material
return 'url_only' unless material.file.attached?
content_type = material.file.blob.content_type.to_s
return 'image' if content_type.start_with?('image/')
return 'video' if content_type.start_with?('video/')
return 'audio' if content_type.start_with?('audio/')
'file_other'
end
def compact_tag tag
return nil unless tag
{ id: tag.id,
name: tag.name,
category: tag.category,
deprecated_at: tag.deprecated_at }
end
end end
+60 -5
ファイルの表示
@@ -2,19 +2,74 @@
module PostRepr module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze BASE_FIELDS = [
:id,
:version_no,
:url,
:title,
:thumbnail_base,
:video_ms,
:original_created_from,
:original_created_before,
:created_at,
:updated_at
].freeze
module_function module_function
def base post, current_user = nil def base post, current_user = nil
json = post.as_json(BASE) json = common(post)
return json.merge(viewed: false) unless current_user json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
viewed = current_user.viewed?(post) def detail post, current_user = nil, parent_posts: [], child_posts: [],
json.merge(viewed:) sibling_posts: { }, related: []
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts
posts.map { |post| card(post) }
end end
def many posts, current_user = nil def many posts, current_user = nil
posts.map { |p| base(p, current_user) } posts.map { |p| base(p, current_user) }
end end
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json post
post
.active_post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end end
+6 -2
ファイルの表示
@@ -2,8 +2,8 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:name, :has_wiki, :material_id] }.freeze methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
module_function module_function
@@ -12,5 +12,9 @@ module TagRepr
parents: tag.parents.map { _1.as_json(BASE) }) parents: tag.parents.map { _1.as_json(BASE) })
end end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) } def many(tags) = tags.map { |t| base(t) }
end end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@
module WikiPageRepr module WikiPageRepr
BASE = { methods: [:title] }.freeze BASE = { methods: [:title, :deprecated_at] }.freeze
module_function module_function
+22
ファイルの表示
@@ -0,0 +1,22 @@
module Gekanator
class AiRunBudget
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
def self.remaining_monthly_budget_jpy
MONTHLY_LIMIT_JPY - monthly_cost_jpy
end
def self.monthly_cost_jpy
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
end
def self.exceeded?
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
end
def self.exceeded_after_next_run?
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
end
end
end
+168
ファイルの表示
@@ -0,0 +1,168 @@
module Gekanator
class QuestionSuggestionAiConverter
# Temporary heuristic converter for #361.
# This creates pending ai_generated questions without external LLM calls;
# accepted questions are still distributed only after admin approval.
TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/
ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/
ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/
ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/
TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/
SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/
def self.call(...) = new(...).call
def initialize suggestion:, user:
@suggestion = suggestion
@user = user
end
def call
suggestion.with_lock do
existing = existing_generated_question
return existing if existing
run = suggestion.gekanator_ai_runs.create!(
model: 'heuristic_converter_v1',
status: 'running',
input_tokens: 0,
output_tokens: 0,
estimated_cost_jpy: 0)
question_attributes = build_question
question =
question_attributes &&
GekanatorQuestion.create!(
**question_attributes,
source: 'ai_generated',
status: 'pending',
gekanator_question_suggestion: suggestion,
created_by: user)
run.update!(status: question ? 'succeeded' : 'failed')
question
end
rescue => error
run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed'
raise error
end
private
attr_reader :suggestion, :user
def existing_generated_question
suggestion
.gekanator_questions
.where(source: 'ai_generated')
.order(id: :desc)
.first
end
def build_question
text = normalized_text
return nil if text.blank?
structured_question_for(text) || post_similarity_question_for(text)
end
def normalized_text
suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip
end
def structured_question_for text
case text
when TITLE_LENGTH_RE
length = Regexp.last_match(1).to_i
return nil if length <= 0
{
text:,
kind: 'title',
condition: {
type: 'title-length-at-least',
length:
},
priority_weight: 0.95
}
when /\A題名に英数字が混じって[ゐい]る[??]\z/
{
text: '題名に英数字が混じってゐる?',
kind: 'title',
condition: { type: 'title-has-ascii' },
priority_weight: 0.95
}
when ORIGINAL_YEAR_RE
year = Regexp.last_match(1).to_i
{
text:,
kind: 'original_date',
condition: { type: 'original-year', year: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_RE
month = Regexp.last_match(1).to_i
return nil unless month.between?(1, 12)
{
text:,
kind: 'original_date',
condition: { type: 'original-month', month: },
priority_weight: 0.95
}
when ORIGINAL_MONTH_DAY_RE
month = Regexp.last_match(1).to_i
day = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12) && day.between?(1, 31)
{
text:,
kind: 'original_date',
condition: {
type: 'original-month-day',
monthDay: "#{ month }-#{ day }"
},
priority_weight: 0.95
}
when TITLE_CONTAINS_RE
title_text = Regexp.last_match(1).to_s.strip
return nil if title_text.blank?
{
text: "題名に「#{ title_text }」が含まれる?",
kind: 'title',
condition: { type: 'title-contains', text: title_text },
priority_weight: 0.95
}
when SOURCE_RE
host = Regexp.last_match(1).to_s.strip
return nil if host.blank?
{
text:,
kind: 'source',
condition: { type: 'source', host: },
priority_weight: 0.95
}
else
nil
end
end
def post_similarity_question_for text
return nil if suggestion.answer == 'unknown'
{
text:,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
priority_weight: 1.0
}
end
end
end
+52
ファイルの表示
@@ -0,0 +1,52 @@
module Gekanator
class QuestionSuggestionPromoter
def self.call(...) = new(...).call
def initialize suggestion:, user:
@suggestion = suggestion
@user = user
end
def call
suggestion.with_lock do
return promoted_question if suggestion.processed?
return suggestion if suggestion.answer == 'unknown'
question = GekanatorQuestion.create!(
text: suggestion.question_text,
kind: 'post_similarity',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.2,
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
gekanator_question_suggestion: suggestion,
created_by: user)
example =
GekanatorQuestionExample.new(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user)
example.record_answer!(
answer: suggestion.answer,
source: 'initial_suggestion',
gekanator_game: suggestion.gekanator_game)
example.save!
suggestion.update!(processed: true)
question
end
end
private
attr_reader :suggestion, :user
def promoted_question
suggestion.gekanator_questions.order(id: :desc).first
end
end
end
+233
ファイルの表示
@@ -0,0 +1,233 @@
require 'json'
require 'jwt'
require 'net/http'
require 'openssl'
require 'tempfile'
require 'uri'
module GoogleDrive
class ApiClient
DRIVE_ENDPOINT = 'https://www.googleapis.com/drive/v3'
TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
NATIVE_FILE_MIME_TYPE_PREFIX = 'application/vnd.google-apps.'
DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.readonly'
def initialize service_account_email: ENV['GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL'],
private_key: ENV['GOOGLE_DRIVE_PRIVATE_KEY'],
private_key_path: ENV['GOOGLE_DRIVE_PRIVATE_KEY_PATH'],
subject: ENV['GOOGLE_DRIVE_SUBJECT']
@service_account_email = service_account_email.to_s
@private_key = load_private_key(private_key, private_key_path)
@subject = subject.to_s.presence
@access_token = nil
end
def list_material_files_under_folder folder_id
files = []
each_material_file_under_folder(folder_id) { |entry| files << entry }
files
end
def each_material_file_under_folder folder_id
return enum_for(__method__, folder_id) unless block_given?
walk_folder(folder_id, nil) do |entry, relative_path|
next if entry['mimeType'] == FOLDER_MIME_TYPE
next if native_file?(entry['mimeType'])
yield build_file_entry(entry, relative_path)
end
end
def fetch_material_file file_id
metadata = get_file(file_id)
return nil if native_file?(metadata['mimeType'])
build_file_entry(metadata, metadata['name'])
end
def download_to_tempfile file_id, filename:
tempfile = Tempfile.new(['material-sync', File.extname(filename.to_s)])
tempfile.binmode
request_binary("/files/#{ file_id }",
{ alt: 'media', supportsAllDrives: true }) do |chunk|
tempfile.write(chunk.b)
end
tempfile.rewind
tempfile
rescue StandardError
tempfile&.close!
raise
end
def extract_file_id value
raw = value.to_s.strip
return nil if raw.blank?
return raw unless raw.include?('/')
uri = URI.parse(raw)
return uri.query.to_s[%r{(?:^|&)id=([^&]+)}, 1] if uri.query.present?
return uri.path[%r{/folders/([^/]+)}, 1] if uri.path.include?('/folders/')
uri.path[%r{/d/([^/]+)}, 1]
rescue URI::InvalidURIError
nil
end
private
def walk_folder folder_id, prefix, &block
list_children(folder_id).each do |entry|
relative_path = MaterialSyncExportPath.build(prefix:,
relative_path: entry['name'])
if entry['mimeType'] == FOLDER_MIME_TYPE
walk_folder(entry['id'], relative_path, &block)
next
end
block.call(entry, relative_path)
end
end
def build_file_entry entry, relative_path
{ id: entry['id'],
name: entry['name'],
mime_type: entry['mimeType'],
relative_path: MaterialSyncExportPath.normalize_path(relative_path),
sha256_checksum: entry['sha256Checksum'],
web_view_link: entry['webViewLink'],
web_content_link: entry['webContentLink'] }
end
def list_children folder_id
files = []
page_token = nil
loop do
response =
request_json('/files', {
q: "'#{ folder_id }' in parents and trashed = false",
fields: 'nextPageToken,files(id,name,mimeType,sha256Checksum,' \
'webViewLink,webContentLink)',
orderBy: 'folder,name',
pageSize: 1000,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
pageToken: page_token }.compact)
files.concat(response.fetch('files'))
page_token = response['nextPageToken']
break if page_token.blank?
end
files
end
def get_file file_id
request_json("/files/#{ file_id }",
fields: 'id,name,mimeType,sha256Checksum,webViewLink,webContentLink',
supportsAllDrives: true)
end
def request_json path, params = {}
response = request(:get, path, params:)
unless response.is_a?(Net::HTTPSuccess)
raise "Google Drive API error: #{ response.code } #{ response.body }"
end
JSON.parse(response.body)
end
def request_binary path, params = {}
request(:get, path, params:) do |response|
unless response.is_a?(Net::HTTPSuccess)
raise "Google Drive download error: #{ response.code } #{ response.body }"
end
response.read_body do |chunk|
yield chunk.b
end
end
end
def request method, path, params: nil
uri = URI(DRIVE_ENDPOINT + path)
uri.query = URI.encode_www_form(params) if params.present?
klass =
case method
when :get
Net::HTTP::Get
else
raise ArgumentError, "Unsupported Google Drive request method: #{ method }"
end
request = klass.new(uri)
request['Authorization'] = "Bearer #{ access_token }"
Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 60) do |http|
if block_given?
http.request(request) do |response|
return yield response
end
else
http.request(request)
end
end
end
def access_token
@access_token ||= fetch_access_token
end
def fetch_access_token
raise 'GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL is required' if @service_account_email.blank?
raise 'Google Drive private key is required' if @private_key.blank?
payload = { iss: @service_account_email,
scope: DRIVE_SCOPE,
aud: TOKEN_ENDPOINT,
exp: 1.hour.from_now.to_i,
iat: Time.current.to_i }
payload[:sub] = @subject if @subject.present?
assertion = JWT.encode(payload, @private_key, 'RS256')
uri = URI(TOKEN_ENDPOINT)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion:)
response =
Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise "Google OAuth error: #{ response.code } #{ response.body }"
end
JSON.parse(response.body).fetch('access_token')
end
def load_private_key private_key, private_key_path
raw = private_key.to_s
raw = raw.gsub('\n', "\n") if raw.present?
raw = File.read(private_key_path) if raw.blank? && private_key_path.present?
return nil if raw.blank?
OpenSSL::PKey::RSA.new(raw)
end
def native_file? mime_type
mime_type.to_s.start_with?(NATIVE_FILE_MIME_TYPE_PREFIX)
end
end
end
+57
ファイルの表示
@@ -0,0 +1,57 @@
require 'digest'
require 'json'
class MaterialFileSha256
def self.blob_metadata blob
metadata = blob.metadata
return metadata if metadata.is_a?(Hash)
return { } if metadata.blank?
JSON.parse(metadata)
rescue JSON::ParserError
{ }
end
def self.metadata_sha256 blob
blob_metadata(blob)['sha256'].presence
end
def self.assign_metadata_sha256! blob, sha256
return if sha256.blank?
metadata = blob_metadata(blob)
metadata['sha256'] = sha256
blob.metadata = metadata
blob.save! if blob.changed?
end
def self.from_blob blob, allow_download: false
sha256 = metadata_sha256(blob)
return sha256 if sha256.present?
return nil unless allow_download
begin
blob.open do |file|
sha256 = Digest::SHA256.file(file.path).hexdigest
assign_metadata_sha256!(blob, sha256)
sha256
end
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
Rails.logger.warn(
"MaterialFileSha256.from_blob failed for blob_id=#{blob.id}: " \
"#{error.class}: #{error.message}",
)
nil
end
end
def self.from_upload upload
tempfile = upload&.tempfile
return nil unless tempfile
tempfile.rewind
Digest::SHA256.file(tempfile.path).hexdigest.tap do
tempfile.rewind
end
end
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class MaterialImportBlockMatcher
def self.match_for_sha256 sha256
return nil if sha256.blank?
MaterialImportBlock.find_by(match_kind: 'sha256', sha256:)
end
end
+56
ファイルの表示
@@ -0,0 +1,56 @@
class MaterialSyncExportPath
class << self
def build prefix: nil, relative_path: nil, filename: nil, source_file_id: nil
path = normalize_path(relative_path)
path = fallback_filename(filename, source_file_id) if path.blank?
normalized_prefix = normalize_path(prefix)
return path if normalized_prefix.blank?
return normalized_prefix if path.blank?
[normalized_prefix, path].join('/')
end
def normalize_path path
raw = path.to_s.delete("\0").tr('\\', '/').strip
raw = raw.sub(%r{\A[A-Za-z]:/+}, '')
raw = raw.sub(%r{\A/+}, '')
return nil if raw.blank?
segments = raw.split('/').filter_map do |segment|
normalized = normalize_segment(segment)
normalized.presence
end
return nil if segments.empty?
segments.join('/')
end
def uniquify path, source_file_id
normalized = normalize_path(path)
return normalized if normalized.blank? || source_file_id.blank?
ext = File.extname(normalized)
base = ext.present? ? normalized.delete_suffix(ext) : normalized
"#{ base }--#{ source_file_id }#{ ext }"
end
private
def fallback_filename filename, source_file_id
name = normalize_path(filename)
return name if name.present?
return nil if source_file_id.blank?
source_file_id.to_s
end
def normalize_segment segment
cleaned = segment.to_s.delete("\0").tr('\\/', '_').strip
return nil if cleaned.blank? || cleaned == '.'
return '__' if cleaned == '..'
cleaned
end
end
end
+259
ファイルの表示
@@ -0,0 +1,259 @@
require 'digest'
class MaterialSyncImporter
Result = Struct.new(:material, :action, :suppressed, :suppression, keyword_init: true)
def self.import! attributes
new(attributes).import!
end
def initialize attributes
@attributes = attributes
end
def import!
source = source_attributes
key = normalized_source_key(source)
raise ArgumentError, 'normalized_source_key is required for material sync' if key.blank?
suppression = suppression_for(source)
if suppression
return Result.new(material: nil,
action: :suppressed,
suppressed: true,
suppression:)
end
material =
Material
.unscoped
.find_or_initialize_by(normalized_source_key: key)
if unchanged?(material, source)
return Result.new(material:,
action: :unchanged,
suppressed: false,
suppression: nil)
end
event_type =
if material.new_record?
:create
elsif material.discarded?
:restore
else
:update
end
uploaded_blob = uploaded_blob!
if @import_block
return Result.new(material: nil,
action: :suppressed,
suppressed: true,
suppression: @import_block)
end
Material.transaction do
if material.persisted?
MaterialVersionRecorder.ensure_snapshot!(material,
created_by_user: @attributes[:updated_by_user])
end
material.assign_attributes(material_attributes_for(material).merge(source))
material.discarded_at = nil if material.respond_to?(:discarded_at=)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
upsert_export_item!(material)
MaterialVersionRecorder.record!(material:,
event_type:,
created_by_user: @attributes[:updated_by_user])
end
MaterialThumbnailGenerator.generate!(material)
Result.new(material:,
action: event_type == :create ? :imported : :updated,
suppressed: false,
suppression: nil)
rescue StandardError
uploaded_blob&.purge_later
raise
ensure
close_tempfile!
end
private
def source_attributes
{ source_kind: @attributes[:source_kind],
source_uri: @attributes[:source_uri],
source_path: @attributes[:source_path],
source_file_id: @attributes[:source_file_id] }
end
def normalized_source_key source
MaterialSyncSuppression.normalize_source_key(source_kind: source[:source_kind],
source_uri: source[:source_uri],
source_path: source[:source_path],
source_file_id: source[:source_file_id])
end
def material_attributes_for material
attrs =
@attributes
.except(:source_kind, :source_uri, :source_path, :source_file_id,
:file_blob, :file_tempfile, :file_downloader,
:filename, :content_type,
:file_sha256, :export_path, :profile, :tag, :url)
if material.new_record?
attrs[:tag] = @attributes[:tag] if @attributes.key?(:tag)
attrs[:url] = @attributes[:url] if @attributes.key?(:url)
return attrs
end
if @attributes.key?(:tag) && @attributes[:tag].present?
attrs[:tag] = @attributes[:tag]
end
if @attributes.key?(:url) && @attributes[:url].present?
attrs[:url] = @attributes[:url]
end
attrs
end
def suppression_for source
if source[:source_kind] == 'google_drive_file' && source[:source_path].present?
return MaterialSyncSuppressionMatcher.match_google_drive_candidate(
drive_file_id: source[:source_file_id],
relative_path: source[:source_path])
end
MaterialSyncSuppressionMatcher.match(**source)
end
def uploaded_blob!
return @attributes[:file_blob] if @attributes[:file_blob]
tempfile = upload_tempfile
return nil unless tempfile
tempfile.rewind
file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest
@attributes[:file_sha256] = file_sha256
@import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
if @import_block
tempfile.rewind
return nil
end
blob = ActiveStorage::Blob.create_and_upload!(
io: tempfile,
filename: @attributes[:filename],
content_type: @attributes[:content_type])
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
tempfile.rewind
blob
end
def upload_tempfile
return @upload_tempfile if defined?(@upload_tempfile)
@upload_tempfile = @attributes[:file_tempfile]
if @upload_tempfile.blank? && @attributes[:file_downloader]
@upload_tempfile = @attributes[:file_downloader].call
end
@upload_tempfile
end
def upsert_export_item! material
export_path = resolved_export_path(material)
return if export_path.blank?
item = material.material_export_items.find_or_initialize_by(profile: effective_profile)
item.export_path = export_path
item.enabled = true
item.created_by_user ||= @attributes[:created_by_user]
item.save!
end
def export_path_taken_by_other? material, profile, export_path
MaterialExportItem
.where(profile:, export_path:)
.where.not(material_id: material.id)
.exists?
end
def close_tempfile!
tempfile = upload_tempfile
return unless tempfile
tempfile.close! unless tempfile.closed?
rescue StandardError
nil
end
def unchanged? material, source
return false if material.new_record? || material.discarded?
return false unless same_source_attributes?(material, source)
return false unless same_export_path?(material)
return false unless same_material_attributes?(material)
same_file_snapshot?(material)
end
def same_source_attributes? material, source
material.source_kind == source[:source_kind] \
&& material.source_uri == source[:source_uri] \
&& material.source_path == source[:source_path] \
&& material.source_file_id == source[:source_file_id]
end
def same_export_path? material
expected = resolved_export_path(material)
current = material.material_export_items.find { |item| item.profile == effective_profile }
return current.blank? if expected.blank?
current&.enabled && current.export_path == expected
end
def same_material_attributes? material
same_tag_attribute?(material) && same_url_attribute?(material)
end
def same_file_snapshot? material
expected_sha256 = @attributes[:file_sha256].to_s.presence
if expected_sha256.present?
return false unless material.file.attached?
return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256
end
!material.file.attached?
end
def effective_profile
@attributes[:profile].presence || 'legacy_drive'
end
def resolved_export_path material
export_path = @attributes[:export_path].to_s.strip
return export_path if export_path.blank?
return export_path unless export_path_taken_by_other?(material, effective_profile, export_path)
MaterialSyncExportPath.uniquify(export_path, @attributes[:source_file_id])
end
def same_tag_attribute? material
return true unless @attributes.key?(:tag)
return true if @attributes[:tag].blank?
material.tag_id == @attributes[:tag].id
end
def same_url_attribute? material
return true unless @attributes.key?(:url)
return true if @attributes[:url].blank?
material.url == @attributes[:url]
end
end
+251
ファイルの表示
@@ -0,0 +1,251 @@
class MaterialSyncRunner
Result = Struct.new(:imported, :updated, :unchanged, :suppressed, :failed,
:errors, keyword_init: true)
def self.sync_enabled!
results = MaterialSyncSource.enabled.order(:id).map { |source| new(source).sync! }
Result.new(imported: results.sum(&:imported),
updated: results.sum(&:updated),
unchanged: results.sum(&:unchanged),
suppressed: results.sum(&:suppressed),
failed: results.sum(&:failed),
errors: results.flat_map(&:errors))
end
def initialize source
@source = source
end
def sync!
result = Result.new(imported: 0, updated: 0, unchanged: 0,
suppressed: 0, failed: 0, errors: [])
if @source.source_kind == 'google_drive_path'
sync_google_drive_path!(result)
else
candidates.each do |candidate|
next if candidate.blank?
sync_candidate!(candidate, result)
end
end
@source.update!(last_synced_at: Time.current)
log_result(result)
result
rescue NotImplementedError, StandardError => e
result.failed += 1
result.errors << { source_id: @source.id, error: e.message }
log_result(result)
result
end
private
def candidates
case @source.source_kind
when 'uri'
[uri_candidate]
when 'google_drive_file'
[google_drive_file_candidate]
when 'legacy_drive_path'
raise NotImplementedError, 'legacy_drive_path material sync is not implemented'
else
raise NotImplementedError, "Unsupported material sync source_kind: #{ @source.source_kind }"
end
end
def uri_candidate
{ source_kind: @source.source_kind,
source_uri: @source.source_uri,
source_path: @source.source_path,
source_file_id: @source.source_file_id,
url: @source.source_uri,
tag: nil,
created_by_user: @source.created_by_user,
updated_by_user: @source.updated_by_user || @source.created_by_user }
end
def sync_candidate! candidate, result
block = MaterialImportBlockMatcher.match_for_sha256(candidate[:file_sha256])
if block
result.suppressed += 1
Rails.logger.info(
material_sync_log(action: 'suppressed',
reason: block.reason,
normalized_source_key: candidate_normalized_source_key(candidate)))
return
end
suppression = suppression_for(candidate)
if suppression
result.suppressed += 1
Rails.logger.info(
material_sync_log(action: 'suppressed',
normalized_source_key: suppression.normalized_source_key))
return
end
import = MaterialSyncImporter.import!(candidate)
result.public_send("#{ import.action }=", result.public_send(import.action) + 1)
rescue StandardError => e
result.failed += 1
result.errors << { source_id: @source.id,
normalized_source_key: candidate_normalized_source_key(candidate),
error: e.message }
Rails.logger.warn(
material_sync_log(action: 'failed',
error: e.message,
normalized_source_key: candidate_normalized_source_key(candidate)))
ensure
close_candidate_tempfile(candidate)
end
def google_drive_path_candidates
folder_id = google_drive_folder_id
Enumerator.new do |entries|
drive_client.each_material_file_under_folder(folder_id).each do |entry|
entries << build_google_drive_candidate(entry)
end
end
end
def sync_google_drive_path! result
folder_id = google_drive_folder_id
scanned_count = 0
drive_client.each_material_file_under_folder(folder_id) do |entry|
scanned_count += 1
sync_candidate!(build_google_drive_candidate(entry), result)
log_google_drive_progress(folder_id, scanned_count, result) if progress_log_scan_count?(scanned_count)
end
log_google_drive_progress(folder_id, scanned_count, result, summary: true)
end
def google_drive_file_candidate
entry = drive_client.fetch_material_file(google_drive_file_id)
return nil unless entry
build_google_drive_candidate(entry, single_file: true)
end
def build_google_drive_candidate entry, single_file: false
relative_path =
if single_file
nil
else
entry[:relative_path]
end
export_path =
MaterialSyncExportPath.build(prefix: @source.export_path_prefix,
relative_path:,
filename: entry[:name],
source_file_id: entry[:id])
{ source_kind: 'google_drive_file',
source_uri: entry[:web_view_link] || google_drive_file_url(entry[:id]),
source_path: relative_path.presence || MaterialSyncExportPath.normalize_path(entry[:name]),
source_file_id: entry[:id],
filename: entry[:name],
content_type: entry[:mime_type],
file_downloader: lambda {
drive_client.download_to_tempfile(entry[:id], filename: entry[:name])
},
file_sha256: entry[:sha256_checksum],
export_path:,
profile: @source.profile,
tag: nil,
url: nil,
created_by_user: @source.created_by_user,
updated_by_user: @source.updated_by_user || @source.created_by_user }
end
def candidate_normalized_source_key candidate
MaterialSyncSuppression.normalize_source_key(source_kind: candidate[:source_kind],
source_uri: candidate[:source_uri],
source_path: candidate[:source_path],
source_file_id: candidate[:source_file_id])
end
def suppression_for candidate
if candidate[:source_kind] == 'google_drive_file' && candidate[:source_path].present?
return MaterialSyncSuppressionMatcher.match_google_drive_candidate(
drive_file_id: candidate[:source_file_id],
relative_path: candidate[:source_path])
end
MaterialSyncSuppressionMatcher.match(source_kind: candidate[:source_kind],
source_uri: candidate[:source_uri],
source_path: candidate[:source_path],
source_file_id: candidate[:source_file_id])
end
def drive_client
@drive_client ||= GoogleDrive::ApiClient.new
end
def google_drive_folder_id
google_drive_target_id.tap do |id|
if id.blank?
raise ArgumentError, 'google_drive_path source_file_id or source_uri is required'
end
end
end
def google_drive_file_id
google_drive_target_id.tap do |id|
if id.blank?
raise ArgumentError, 'google_drive_file source_file_id or source_uri is required'
end
end
end
def google_drive_target_id
@source.source_file_id.presence || drive_client.extract_file_id(@source.source_uri)
end
def google_drive_file_url file_id
"https://drive.google.com/file/d/#{ file_id }/view"
end
def close_candidate_tempfile candidate
tempfile = candidate[:file_tempfile]
return unless tempfile
tempfile.close! unless tempfile.closed?
rescue StandardError
nil
end
def log_result result
Rails.logger.info(material_sync_log(imported: result.imported,
updated: result.updated,
unchanged: result.unchanged,
suppressed: result.suppressed,
failed: result.failed))
end
def progress_log_scan_count? scanned_count
scanned_count == 1 || (scanned_count % 50).zero?
end
def log_google_drive_progress folder_id, scanned_count, result, summary: false
Rails.logger.info(
material_sync_log(folder_id:,
scanned_count:,
imported: result.imported,
updated: result.updated,
unchanged: result.unchanged,
suppressed: result.suppressed,
failed: result.failed,
progress: summary ? 'summary' : 'scan'))
end
def material_sync_log fields
{ material_sync_source_id: @source.id,
material_sync_source_name: @source.name }.merge(fields).to_json
end
end
+68
ファイルの表示
@@ -0,0 +1,68 @@
class MaterialSyncSuppressionMatcher
def self.match source_kind:,
source_uri: nil,
drive_path: nil,
drive_file_id: nil,
source_path: nil,
source_file_id: nil
key = MaterialSyncSuppression.normalize_source_key(source_kind:,
source_uri:,
drive_path:,
drive_file_id:,
source_path:,
source_file_id:)
return nil if key.blank?
MaterialSyncSuppression.find_by(normalized_source_key: key)
end
def self.match_google_drive_candidate drive_file_id:, relative_path:
normalized_path = validate_relative_path!(relative_path)
exact = [
normalize_key('google_drive_file', drive_file_id),
normalize_key('google_drive_path', normalized_path),
normalize_key('legacy_drive_path', normalized_path),
].compact
suppression =
MaterialSyncSuppression.find_by(normalized_source_key: exact)
return suppression if suppression
MaterialSyncSuppression
.where(source_kind: ['google_drive_path_prefix', 'legacy_drive_path_prefix'])
.where('drive_path = :path OR :path LIKE CONCAT(drive_path, "/%")', path: normalized_path)
.order(:id)
.first
end
def self.suppressed?(...)
match(...).present?
end
def self.validate_relative_path! relative_path
raw = relative_path.to_s.delete("\0").tr('\\', '/').strip
raise ArgumentError, 'relative_path is required' if raw.blank?
raise ArgumentError, 'relative_path must be relative' if raw.start_with?('/')
raise ArgumentError, 'relative_path must be relative' if raw.match?(/\A[A-Za-z]:\//)
if raw.start_with?('My Drive/', 'マイドライブ/')
raise ArgumentError, 'relative_path must be relative to source folder'
end
raise ArgumentError, 'relative_path must not contain //' if raw.include?('//')
raise ArgumentError, 'relative_path must not end with /' if raw.end_with?('/')
parts = raw.split('/')
if parts.any? { |part| part.in?(['.', '..']) }
raise ArgumentError, 'relative_path must not contain dot segments'
end
raw
end
def self.normalize_key source_kind, value
return nil if value.blank?
MaterialSyncSuppression.normalize_source_key(source_kind:, source_path: value,
source_file_id: value)
end
end
+51
ファイルの表示
@@ -0,0 +1,51 @@
class MaterialSyncSuppressionRegistrar
def self.create! attributes, created_by_user:
new(attributes, created_by_user:).create!
end
def initialize attributes, created_by_user:
@attributes = attributes
@created_by_user = created_by_user
end
def create!
suppression = nil
MaterialSyncSuppression.transaction do
suppression = MaterialSyncSuppression.create!(
@attributes.merge(created_by_user: @created_by_user))
discard_existing_materials!(suppression)
end
suppression
end
private
def discard_existing_materials! suppression
matching_materials(suppression).find_each do |material|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @created_by_user)
material.discard!
MaterialVersionRecorder.record!(material:,
event_type: :discard,
created_by_user: @created_by_user)
end
end
def matching_materials suppression
materials = Material.unscoped.kept
case suppression.source_kind
when 'google_drive_path', 'legacy_drive_path'
materials.where(source_path: suppression.drive_path)
when 'google_drive_path_prefix', 'legacy_drive_path_prefix'
path = suppression.drive_path.to_s
materials.where(
'source_path = :path OR source_path LIKE :prefix',
path:,
prefix: "#{ path }/%")
else
materials.where(normalized_source_key: suppression.normalized_source_key)
end
end
end
+126
ファイルの表示
@@ -0,0 +1,126 @@
# frozen_string_literal: true
require 'mini_magick'
require 'open3'
require 'tempfile'
class MaterialThumbnailGenerator
SIZE = '180x180'
class << self
def generate! material
new(material).generate!
end
end
def initialize material
@material = material
@ffmpeg_stderr = []
end
def generate!
return log_result(:no_file) unless @material.file.attached?
return log_result(:unsupported_content_type) unless image? || video?
@material.file.blob.open do |file|
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
return log_result(:generation_failed) unless thumbnail
return attach_thumbnail(thumbnail)
end
rescue ActiveStorage::FileNotFoundError => e
log_result(:file_not_found, error: e)
rescue MiniMagick::Error => e
log_result(:mini_magick_error, error: e)
rescue ArgumentError, StandardError => e
log_result(:generation_failed, error: e)
end
private
def image? = content_type.start_with?('image/')
def video? = content_type.start_with?('video/')
def content_type
return nil unless @material.file.attached?
@material.file.blob.content_type.to_s
end
def image_thumbnail path
image = MiniMagick::Image.open(path)
image.resize(SIZE)
image.format('jpg')
image
end
def video_thumbnail path
[1, 0].each do |seconds|
tempfile = Tempfile.new(['material-thumbnail', '.jpg'])
tempfile.close
ok = extract_video_frame(path, tempfile.path, seconds)
next unless ok && File.size?(tempfile.path)
return image_thumbnail(tempfile.path)
ensure
tempfile&.unlink
end
nil
end
def extract_video_frame input_path, output_path, seconds
_stdout, stderr, status =
Open3.capture3('ffmpeg',
'-y',
'-ss', seconds.to_s,
'-i', input_path,
'-frames:v', '1',
'-f', 'image2',
output_path)
@ffmpeg_stderr << stderr if stderr.present?
status.success?
rescue Errno::ENOENT => e
@ffmpeg_stderr << "ffmpeg unavailable: #{ e.message }"
false
end
def attach_thumbnail image
blob = nil
File.open(image.path) do |io|
blob = ActiveStorage::Blob.create_and_upload!(
io:,
filename: 'material-thumbnail.jpg',
content_type: 'image/jpeg')
end
@material.thumbnail.attach(blob)
log_result(:attached)
rescue StandardError => e
blob&.purge_later
log_result(:attach_failed, error: e)
end
def log_result result, error: nil
log_payload = { material_id: @material.id,
file_blob_id: file_blob_id,
content_type:,
result:,
error_class: error&.class&.name,
error_message: error&.message,
ffmpeg_stderr: @ffmpeg_stderr.join("\n").presence }
if [:attached, :no_file, :unsupported_content_type].include?(result)
Rails.logger.info("Material thumbnail generation: #{ log_payload.to_json }")
else
Rails.logger.warn("Material thumbnail generation: #{ log_payload.to_json }")
end
result
end
def file_blob_id
return nil unless @material.file.attached?
@material.file.blob.id
end
end
+73
ファイルの表示
@@ -0,0 +1,73 @@
class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, created_by_user:, file_snapshot:).record!
end
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
@file_snapshot = file_snapshot
super(record: material, event_type:, created_by_user:)
end
def self.ensure_snapshot! material, created_by_user:
return if material.material_versions.exists?
record!(material:, event_type: :create,
created_by_user: material.created_by_user || created_by_user)
end
private
def version_class = MaterialVersion
def version_association = :material_versions
def record_key = :material
def snapshot_attributes
blob = @record.file.attached? ? @record.file.blob : nil
file_snapshot = build_file_snapshot(blob)
{ url: @record.url,
parent: @record.parent,
tag: @record.tag,
tag_name: @record.tag&.name,
tag_category: @record.tag&.category,
source_kind: @record.source_kind,
source_uri: @record.source_uri,
source_path: @record.source_path,
source_file_id: @record.source_file_id,
normalized_source_key: @record.normalized_source_key,
export_paths_json: @record.snapshot_export_paths,
discarded_at: @record.discarded_at,
file_blob_id: file_snapshot[:file_blob_id],
file_filename: file_snapshot[:file_filename],
file_content_type: file_snapshot[:file_content_type],
file_byte_size: file_snapshot[:file_byte_size],
file_checksum: file_snapshot[:file_checksum],
file_sha256: file_snapshot[:file_sha256] }
end
def build_file_snapshot blob
return @file_snapshot if @file_snapshot
return empty_file_snapshot unless blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: MaterialFileSha256.metadata_sha256(blob) }
end
def empty_file_snapshot
{ file_blob_id: nil,
file_filename: nil,
file_content_type: nil,
file_byte_size: nil,
file_checksum: nil,
file_sha256: nil }
end
def event_types = self.class::EVENT_TYPES
end
+148
ファイルの表示
@@ -0,0 +1,148 @@
require 'stringio'
require 'zlib'
# Initial implementation keeps every file payload and the final ZIP in memory.
# Keep this service boundary stable so job/cached export paths can replace it later.
class MaterialZipExporter
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)
class EmptyExportError < StandardError; end
class DuplicatePathError < StandardError; end
class MissingFileError < StandardError
attr_reader :missing_files
def initialize missing_files
@missing_files = missing_files
super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
end
end
def initialize profile: 'legacy_drive', tag_id: nil
@profile = profile.presence || 'legacy_drive'
@tag_id = tag_id.presence
end
def export
entries = build_entries
raise EmptyExportError if entries.empty?
ZipWriter.write(entries)
end
private
def build_entries
rows = MaterialExportItem
.enabled
.includes(material: { file_attachment: :blob })
.joins(:material)
.merge(Material.kept)
.where(profile: @profile)
.order(:export_path)
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
missing_files = []
entries = rows.filter_map do |item|
material = item.material
next unless material.file.attached?
data = download_blob(item, missing_files)
next unless data
Entry.new(path: item.export_path,
data:,
mtime: material.updated_at || Time.current)
end
raise MissingFileError.new(missing_files) if missing_files.any?
paths = entries.map(&:path)
duplicated = paths.find { |path| paths.count(path) > 1 }
raise DuplicatePathError, duplicated if duplicated
entries
end
def download_blob item, missing_files
blob = item.material.file.blob
blob.download
rescue ActiveStorage::FileNotFoundError
missing_files << MissingFile.new(
material_id: item.material_id,
export_path: item.export_path,
blob_id: blob.id,
filename: blob.filename.to_s,
)
nil
end
class ZipWriter
VERSION_NEEDED = 20
GP_FLAG = 0x0800
COMPRESSION_STORE = 0
def self.write entries
new(entries).write
end
def initialize entries
@entries = entries
@central_directory = []
end
def write
io = StringIO.new(''.b)
@entries.each do |entry|
write_entry(io, entry)
end
central_start = io.pos
@central_directory.each { |header| io.write(header) }
central_size = io.pos - central_start
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
central_size, central_start, 0].pack('VvvvvVVv'))
io.string
end
private
def write_entry io, entry
path = entry.path.b
data = entry.data.b
crc32 = Zlib.crc32(data)
dos_time, dos_date = dos_timestamp(entry.mtime)
offset = io.pos
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
path.bytesize, 0].pack('VvvvvvVVVvv')
io.write(local_header)
io.write(path)
io.write(data)
@central_directory << central_header(path:, crc32:, size: data.bytesize,
dos_time:, dos_date:, offset:)
end
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
offset].pack('VvvvvvvVVVvvvvvVV') + path
end
def dos_timestamp time
local = time.to_time
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
[dos_time, dos_date]
end
end
end
+2 -1
ファイルの表示
@@ -23,8 +23,9 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title, { title: @record.title,
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
parent_id: @record.parent_id, parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before } original_created_before: @record.original_created_before }
end end
+21
ファイルの表示
@@ -0,0 +1,21 @@
module Preview
class HtmlMetadataExtractor
IMAGE_SELECTORS = [
'meta[property="og:image"]',
'meta[name="twitter:image"]',
'meta[name="thumbnail"]'
].freeze
def self.extract(response)
document = Nokogiri::HTML.parse(response.body)
image_url = IMAGE_SELECTORS.filter_map {
document.at_css(_1)&.[]('content')&.strip.presence
}.first
{ title: document.at_css('title')&.text&.strip,
image_url: image_url ? URI.join(response.url, image_url).to_s : nil }
rescue URI::InvalidURIError
{ title: document.at_css('title')&.text&.strip, image_url: nil }
end
end
end
+122
ファイルの表示
@@ -0,0 +1,122 @@
require 'net/http'
require 'json'
module Preview
class HttpFetcher
class FetchFailed < StandardError; end
class FetchTimeout < FetchFailed; end
class ResponseTooLarge < FetchFailed; end
MAX_REDIRECTS = 5
DEFAULT_MAX_BYTES = 5.megabytes
Response = Data.define(:body, :content_type, :url)
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
uri, addresses = UrlSafety.validate(raw_url)
response = request(uri, addresses.first, max_bytes)
if response.is_a?(Net::HTTPRedirection)
location = response['location']
if redirects.zero?
log_failure(:redirect_limit,
url: uri.to_s,
redirects:,
location:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect が多すぎます.'
end
if location.blank?
log_failure(:blank_redirect_location,
url: uri.to_s,
redirects:,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, 'redirect 先が不正です.'
end
redirect_url =
begin
URI.join(uri, location).to_s
rescue URI::InvalidURIError => e
log_failure(:invalid_redirect_location,
url: uri.to_s,
redirects:,
location:,
error: e.class.name,
message: e.message)
raise FetchFailed, 'redirect 先が不正です.'
end
return fetch(redirect_url, max_bytes:, redirects: redirects - 1)
end
unless response.is_a?(Net::HTTPSuccess)
log_failure(:http_status,
url: uri.to_s,
code: response.code,
content_type: response['content-type'],
content_length: response['content-length'])
raise FetchFailed, "外部サーバーが HTTP #{ response.code } を返しました."
end
Response.new(response.body, response['content-type'].to_s, uri.to_s)
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchFailed, e.message
end
def self.request(uri, ip_address, max_bytes)
http = Net::HTTP.new(uri.host, uri.port)
http.ipaddr = ip_address
http.use_ssl = uri.scheme == 'https'
http.open_timeout = 5
http.read_timeout = 8
http.write_timeout = 5
request = Net::HTTP::Get.new(uri)
request['User-Agent'] = 'BTRC-Hub thumbnail preview'
request['Accept'] = 'text/html,image/*;q=0.9,*/*;q=0.1'
http.request(request) do |response|
length = response['content-length'].to_i
if length > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
body = +''
response.read_body do |chunk|
body << chunk
next unless body.bytesize > max_bytes
log_failure(:response_too_large,
url: uri.to_s,
content_type: response['content-type'],
content_length: response['content-length'],
bytes_read: body.bytesize,
max_bytes:)
raise ResponseTooLarge, '外部データが大きすぎます.'
end
response.instance_variable_set(:@body, body)
response.instance_variable_set(:@read, true)
return response
end
end
def self.log_failure(reason, **payload)
Rails.logger.warn("preview_http_fetcher_failure #{ { reason:, **payload }.to_json }")
end
private_class_method :request, :log_failure
end
end
+31
ファイルの表示
@@ -0,0 +1,31 @@
module Preview
class KnownSiteExtractor
def self.thumbnail_url(uri)
youtube_thumbnail(uri)
end
def self.niconico_video_id(uri)
case uri.host&.downcase
when 'www.nicovideo.jp', 'nicovideo.jp'
uri.path[%r{\A/watch/(sm\d+)\z}, 1]
when 'nico.ms'
uri.path[%r{\A/(sm\d+)\z}, 1]
end
end
def self.youtube_thumbnail(uri)
id =
case uri.host&.downcase
when 'youtu.be'
uri.path.split('/').reject(&:blank?).first
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
end
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
end
private_class_method :youtube_thumbnail
end
end
+95
ファイルの表示
@@ -0,0 +1,95 @@
module Preview
class ThumbnailFetcher
class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze
HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes
def self.fetch(raw_url)
uri, = UrlSafety.validate(raw_url)
known_url = KnownSiteExtractor.thumbnail_url(uri)
image = fetch_image_or_nil(known_url) if known_url
return image if image
niconico_url = niconico_thumbnail_url(uri)
image = fetch_image_or_nil(niconico_url) if niconico_url
return image if image
page = HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES)
metadata = HtmlMetadataExtractor.extract(page)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
fetch_image!(metadata[:image_url])
end
def self.title(raw_url)
uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract(
HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES))[:title]
end
def self.fetch_image_or_nil(url)
return nil if url.blank?
response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type)
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::FetchFailed
nil
end
def self.fetch_image!(url)
response = HttpFetcher.fetch(url)
unless allowed_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::ResponseTooLarge
raise
rescue HttpFetcher::FetchFailed
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
def self.niconico_thumbnail_url(uri)
video_id = KnownSiteExtractor.niconico_video_id(uri)
return nil if video_id.blank?
response = HttpFetcher.fetch("https://ext.nicovideo.jp/api/getthumbinfo/#{ video_id }",
max_bytes: NICONICO_XML_MAX_BYTES)
xml = Nokogiri::XML(response.body)
return nil unless xml.at_xpath('/nicovideo_thumb_response/@status')&.value == 'ok'
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
rescue Nokogiri::XML::SyntaxError => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
video_id:,
error: e.class.name,
message: e.message }.to_json }")
nil
end
def self.allowed_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
end
private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url,
:allowed_image_content_type?
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
require 'resolv'
require 'ipaddr'
require 'uri'
module Preview
class UrlSafety
class UnsafeUrl < StandardError; end
FORBIDDEN_NETWORKS = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
'169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24',
'192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4',
'240.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10',
'ff00::/8', '2001:db8::/32', '::ffff:0:0/96'
].map { IPAddr.new(_1) }.freeze
def self.validate(raw_url)
value = raw_url.to_s.strip
if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
unless value.match?(/\Ahttps?:\/\//i)
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
else
value = "http://#{ value }"
end
uri = URI.parse(value)
unless ['http', 'https'].include?(uri.scheme&.downcase) && uri.host.present?
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
raise UnsafeUrl, 'userinfo つき URL は使用できません.' if uri.userinfo.present?
addresses = Resolv.getaddresses(uri.host)
raise UnsafeUrl, 'URL のホストを解決できません.' if addresses.empty?
parsed_addresses = addresses.map { IPAddr.new(_1) }
if parsed_addresses.any? { |address| forbidden?(address) }
raise UnsafeUrl, '安全でない接続先は使用できません.'
end
[uri, parsed_addresses.map(&:to_s)]
rescue Resolv::ResolvError
raise UnsafeUrl, 'URL のホストを解決できません.'
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
raise UnsafeUrl, 'URL が不正です.'
end
def self.forbidden?(address)
FORBIDDEN_NETWORKS.any? { _1.include?(address) }
end
private_class_method :forbidden?
end
end
+3 -2
ファイルの表示
@@ -1,6 +1,6 @@
module Similarity module Similarity
class Calc class Calc
def self.call model, tgt def self.call model, tgt, scope: nil
similarity_model = "#{ model.name }Similarity".constantize similarity_model = "#{ model.name }Similarity".constantize
# 最大保存件数 # 最大保存件数
@@ -8,7 +8,8 @@ module Similarity
similarity_model.delete_all similarity_model.delete_all
posts = model.includes(tgt).select(:id).to_a scope ||= model.all
posts = scope.includes(tgt).select(:id).to_a
tag_ids = { } tag_ids = { }
tag_cnts = { } tag_cnts = { }
+1
ファイルの表示
@@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes def snapshot_attributes
{ name: @record.name, { name: @record.name,
category: @record.category, category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: @record.snapshot_aliases.join(' '), aliases: @record.snapshot_aliases.join(' '),
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
+119
ファイルの表示
@@ -0,0 +1,119 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
ELIGIBLE_POST_URL_CONDITION =
["url LIKE '%nicovideo.jp%'",
"url LIKE '%youtube.com/watch%'",
"url LIKE '%youtu.be/%'"]
.join(' OR ')
def initialize theatre:
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where(ELIGIBLE_POST_URL_CONDITION)
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: light_tag_json(tag),
penalty: }
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json candidates
candidates.map { |candidate|
{ post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
end
def light_post_json post
{ id: post.id,
title: post.title,
url: post.url }
end
def light_tag_json tag
{ id: tag.id,
name: tag.name,
category: tag.category }
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+37 -11
ファイルの表示
@@ -16,19 +16,20 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id) @record = record_class.unscoped.lock.find(@record.id)
latest = latest_version latest = latest_version
if !(latest) && @event_type != 'create' validate_version_sequence!(latest)
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
attrs = snapshot_attributes attrs = snapshot_attributes
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
end end
end end
@@ -45,10 +46,34 @@ class VersionRecorder
created_by_user: @created_by_user } created_by_user: @created_by_user }
end end
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } 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! def validate_event_type!
return if EVENT_TYPES.include?(@event_type) return if event_types.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }" raise ArgumentError, "Invalid event_type: #{ @event_type }"
end end
@@ -59,4 +84,5 @@ class VersionRecorder
def snapshot_attributes = raise NotImplementedError def snapshot_attributes = raise NotImplementedError
def record_class = @record.class def record_class = @record.class
def event_types = self.class::EVENT_TYPES
end end
+59 -40
ファイルの表示
@@ -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] = {
body: lines[i], sha256: sha,
created_at: now, body: lines[i],
updated_at: now } created_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
+21
ファイルの表示
@@ -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
+73
ファイルの表示
@@ -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
ファイルの表示
@@ -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
+32
ファイルの表示
@@ -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
+34 -1
ファイルの表示
@@ -10,6 +10,7 @@ Rails.application.routes.draw 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
@@ -23,6 +24,7 @@ Rails.application.routes.draw do
patch '', action: :update patch '', action: :update
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end
@@ -61,6 +63,29 @@ Rails.application.routes.draw do
end end
end end
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games' do
member do
get :extra_questions
post :extra_question_answers
end
end
resources :posts, only: [:index], controller: '/gekanator_posts'
resources :questions, only: [:index], controller: '/gekanator_questions'
resources :question_suggestions,
only: [:create],
controller: '/gekanator_question_suggestions' do
member do
post :ai_convert
end
end
end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
get 'users/theme_slots', to: 'user_theme_slots#index'
put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update'
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
collection do collection do
post :verify post :verify
@@ -83,10 +108,18 @@ Rails.application.routes.draw do
member do member do
put :watching put :watching
patch :next_post patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end end
resources :comments, controller: :theatre_comments, only: [:index, :create] resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
get 'materials/download.zip', to: 'materials#download'
get 'materials/versions', to: 'material_versions#index'
resources :material_sync_suppressions, path: 'materials/suppressions', only: [:index, :create]
resources :materials, only: [:index, :show, :create, :update, :destroy] resources :materials, only: [:index, :show, :create, :update, :destroy]
end end
+8
ファイルの表示
@@ -17,3 +17,11 @@ 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' rake 'tag_similarity:calc', environment: 'production'
end end
every 1.day, at: '7:50 am' do
rake 'nico:export', environment: 'production'
end
every :hour do
rake 'post:sync', environment: 'production'
end
+91
ファイルの表示
@@ -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
+24
ファイルの表示
@@ -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

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