コミットを比較

..

19 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 5cf46406a9 #268 2026-07-08 00:00:03 +09:00
みてるぞ 29c385ed9f #268 2026-07-07 23:41:21 +09:00
みてるぞ abeee76ddd #268 2026-07-07 23:22:20 +09:00
みてるぞ 347a0ebbba #268 2026-07-07 23:04:19 +09:00
みてるぞ f8e8d8fbb0 #268 2026-07-07 22:35:09 +09:00
みてるぞ 33f9c6602f #268 2026-07-07 22:34:58 +09:00
みてるぞ 2d6af0aa5b #268 2026-07-07 21:01:57 +09:00
みてるぞ c0879ac117 #268 2026-07-07 20:57:12 +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
177個のファイルの変更16695行の追加1917行の削除
+699 -10
ファイルの表示
@@ -106,6 +106,10 @@ npm run preview
- 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 `)`.
@@ -132,6 +136,10 @@ npm run preview
- 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:
@@ -196,8 +204,18 @@ const value =
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
- For API behavior changes, add or update request specs under
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.
@@ -205,8 +223,8 @@ const value =
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
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.
@@ -221,24 +239,116 @@ const value =
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
- `<a href="#">` is acceptable for event-only controls when it fits the local
UI pattern. Do not use `<a>` for internal navigation or other non-external
links.
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` 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, prefer modern kana usage and natural current
phrasing over historical spellings or awkward literal wording.
- 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 TSX style
### Frontend TypeScript and TSX style
- Preserve the local TSX formatting 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
@@ -252,6 +362,585 @@ const value =
- 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:
@@ -287,7 +976,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
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.
@@ -304,4 +993,4 @@ A task is complete only when:
explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
- user-facing behaviour is documented when needed.
+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
+470 -34
ファイルの表示
@@ -1,4 +1,11 @@
require 'set'
class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -8,54 +15,100 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit
tag_id = params[:tag_id].presence
parent_id = params[:parent_id].presence
filters = material_index_filters
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 = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id
q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
q = apply_material_query(q, filters[:q]) if filters[:q].present?
q = apply_material_media_kind(q, filters[:media_kind])
count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
count = q.distinct.count(:id)
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
def show
material =
Material
.includes(:tag)
.includes(:tag, :material_export_items)
.with_attached_thumbnail
.with_attached_file
.find_by(id: params[:id])
return head :not_found unless material
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
material = nil
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)
begin
Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
if material.save
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end
if material
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render_validation_error material
@@ -71,29 +124,44 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
file_sha256 = MaterialFileSha256.from_upload(file)
url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.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)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
material.update!(tag:, url:, updated_by_user: current_user)
if file
material.file.attach(file)
else
material.file.purge
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
elsif params.key?(:url) && url.present? && file.blank?
material.file.detach
end
material.save!
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
end
rescue StandardError
uploaded_blob&.purge_later
raise
end
if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render_validation_error material
end
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url)
end
def destroy
@@ -103,7 +171,375 @@ class MaterialsController < ApplicationController
material = Material.find_by(id: params[:id])
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
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 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
+5
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionsController < ApplicationController
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -74,6 +75,10 @@ class PostVersionsController < ApplicationController
current: row.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),
original_created_from: {
current: row.original_created_from&.iso8601,
+138 -28
ファイルの表示
@@ -1,6 +1,10 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index
url = params[:url].presence
title = params[:title].presence
@@ -45,7 +49,9 @@ class PostsController < ApplicationController
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -97,7 +103,9 @@ class PostsController < ApplicationController
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
@@ -110,7 +118,9 @@ class PostsController < ApplicationController
post =
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
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
@@ -125,7 +135,7 @@ class PostsController < ApplicationController
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
.merge(tags: build_tag_tree_for(post))
end
def create
@@ -140,24 +150,27 @@ class PostsController < ApplicationController
original_created_from = params[:original_created_from]
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,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) if thumbnail.present?
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end
@@ -167,6 +180,12 @@ class PostsController < ApplicationController
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
@@ -223,6 +242,8 @@ class PostsController < ApplicationController
original_created_from:,
original_created_before:,
tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:)
snapshot_to_apply =
@@ -253,12 +274,16 @@ class PostsController < ApplicationController
post.reload
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
rescue Tag::NicoTagNormalisationError
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
@@ -357,7 +382,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags
def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t|
t.save! if t.new_record?
end
@@ -376,13 +401,21 @@ class PostsController < ApplicationController
end
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for tags
tags = tags.reject(&:deprecated?).to_a
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -397,6 +430,9 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids
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 = { }
@@ -404,8 +440,10 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id]
return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [])
return TagRepr.inline(tag).merge(children: [], sections:)
end
if memo.key?(tag_id)
@@ -417,7 +455,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:)
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
@@ -486,6 +524,7 @@ class PostsController < ApplicationController
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),
@@ -498,6 +537,7 @@ class PostsController < ApplicationController
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),
@@ -505,16 +545,41 @@ class PostsController < ApplicationController
end
def editable_tag_names_from_post post
post.tags.not_nico.where(deprecated_at: nil)
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
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: incoming_tag_names_for_snapshot(tag_names),
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
@@ -537,11 +602,8 @@ class PostsController < ApplicationController
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
deny_deprecated: true)
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
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:,
@@ -562,6 +624,8 @@ class PostsController < ApplicationController
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, 'オリジナルの作成日時(より前)',
@@ -625,11 +689,14 @@ class PostsController < ApplicationController
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])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true)
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
@@ -637,14 +704,17 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
sync_post_tags!(post, tags)
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, :original_created_from, :original_created_before].map {
[:title, :video_ms, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
@@ -688,4 +758,44 @@ class PostsController < ApplicationController
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
+169 -42
ファイルの表示
@@ -82,8 +82,9 @@ class TagsController < ApplicationController
def with_depth
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any')
graph = build_with_depth_graph
graph = build_with_depth_graph(material_filter)
tag_ids =
if parent_tag_id
@@ -92,19 +93,9 @@ class TagsController < ApplicationController
visible_root_tag_ids(graph)
end
tags =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a
render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
children: [])
}
render json: tag_ids
.sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
.map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
end
def autocomplete
@@ -229,13 +220,16 @@ class TagsController < ApplicationController
def materials_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag
render json: build_tag_children(tag)
graph = build_with_depth_graph(material_filter)
render json: build_tag_children(tag, graph:)
end
def update_all
@@ -251,10 +245,7 @@ class TagsController < ApplicationController
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
if (name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
end
return unless validate_tag_rename(tag, name)
alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq
@@ -280,7 +271,7 @@ class TagsController < ApplicationController
else
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
end
tag.tag_name.update!(name:)
rename_tag_name!(tag, name) if name_changed
alias_names << old_name if name_changed
alias_names.delete(name)
@@ -316,6 +307,8 @@ class TagsController < ApplicationController
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -327,7 +320,7 @@ class TagsController < ApplicationController
name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.tag_name.update!(name:) if name.present?
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)
@@ -348,7 +341,14 @@ class TagsController < ApplicationController
private
def build_with_depth_graph
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] = [] }
@@ -361,23 +361,37 @@ class TagsController < ApplicationController
parent_ids_by_child_id.keys +
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
tags_by_id = Tag.where(id: tag_ids)
.pluck(:id, :category, :deprecated_at)
.each_with_object({ }) do |(id, category, deprecated_at), h|
h[id] = { category:, deprecated: deprecated_at.present? }
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_child_tag_ids_by_parent_id: { },
visible_subtree_by_tag_id: { },
material_filter: }
end
def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, attrs|
next unless with_depth_visible_tag?(attrs)
next unless visible_root_tag?(tag_id, graph)
root_ids = Set.new
tag_id
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
@@ -428,25 +442,77 @@ class TagsController < ApplicationController
return
end
visible_ids << tag_id if with_depth_visible_tag?(tag)
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
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
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 build_tag_children tag
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
file = nil
content_type = nil
if material&.file&.attached?
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type
tag_graph = graph && graph[:tags_by_id][tag.id]
material =
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
children = tag.children.sort_by(&:name)
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end
TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
children: children.map { build_tag_children(_1, graph:) },
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
@@ -468,6 +534,67 @@ class TagsController < ApplicationController
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
def update_aliases! tag, alias_names
alias_names = alias_names.uniq
+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
+43 -2
ファイルの表示
@@ -1,6 +1,9 @@
class Material < ApplicationRecord
include MyDiscard
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
'legacy_drive_path'].freeze
default_scope -> { kept }
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 :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 :tag_must_be_material_category
@@ -23,8 +35,37 @@ class Material < ApplicationRecord
file.blob.content_type
end
def file_byte_size
return nil unless file&.attached?
file.blob.byte_size
end
def file_filename
return nil unless file&.attached?
file.blob.filename.to_s
end
def snapshot_export_paths
material_export_items.order(:profile).pluck(:profile, :export_path).to_h
end
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
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
+52 -7
ファイルの表示
@@ -1,5 +1,19 @@
class Post < ApplicationRecord
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 :uploaded_user, class_name: 'User', optional: true
@@ -46,6 +60,7 @@ class Post < ApplicationRecord
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
validate :url_must_be_http_url
@@ -69,7 +84,42 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
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)
@@ -87,12 +137,7 @@ class Post < ApplicationRecord
def resized_thumbnail!
return unless thumbnail.attached?
image = MiniMagick::Image.read(thumbnail.download)
image.resize '180x180'
thumbnail.purge
thumbnail.attach(io: File.open(image.path),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
end
private
+6
ファイルの表示
@@ -10,6 +10,12 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
+20
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -5,6 +5,7 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range
+47 -2
ファイルの表示
@@ -1,7 +1,52 @@
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
validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 }
validates :value, presence: true
validates :user_id, uniqueness: 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
+112 -3
ファイルの表示
@@ -17,6 +17,16 @@ class Tag < ApplicationRecord
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 :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -105,26 +115,60 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
raw_name = name
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|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name]
end
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id)
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end
def self.expand_parent_tags tags
@@ -150,6 +194,45 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id }
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:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
@@ -246,6 +329,32 @@ class Tag < ApplicationRecord
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, 'ニコタグは廃止できません.'
+24
ファイルの表示
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself
validate :parent_tag_mustnt_create_cycle
private
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
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
+2 -1
ファイルの表示
@@ -7,7 +7,8 @@ class User < ApplicationRecord
has_many :created_posts,
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 :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
+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
extend ActiveSupport::Concern
DEFAULT_EVENT_TYPE_MAP = { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }.freeze
def readonly? = persisted?
included do
event_type_map = if const_defined?(:EVENT_TYPE_MAP, false)
const_get(:EVENT_TYPE_MAP)
else
DEFAULT_EVENT_TYPE_MAP
end
belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
enum :event_type, event_type_map, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true
+87 -2
ファイルの表示
@@ -2,7 +2,9 @@
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],
include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE,
@@ -15,10 +17,93 @@ module MaterialRepr
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
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
def many materials, host:
materials.map { |m| base(m, host:) }
end
def list material, host:
{ id: material.id,
version_no: material.version_no,
url: material.url,
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
+12 -3
ファイルの表示
@@ -8,6 +8,7 @@ module PostRepr
:url,
:title,
:thumbnail_base,
:video_ms,
:original_created_from,
:original_created_before,
:created_at,
@@ -18,7 +19,7 @@ module PostRepr
def base post, current_user = nil
json = common(post)
json['tags'] = tag_json(post.tags)
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
@@ -52,8 +53,16 @@ module PostRepr
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
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
+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
+1
ファイルの表示
@@ -23,6 +23,7 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
+2 -1
ファイルの表示
@@ -73,7 +73,7 @@ class VersionRecorder
end
def validate_event_type!
return if EVENT_TYPES.include?(@event_type)
return if event_types.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }"
end
@@ -84,4 +84,5 @@ class VersionRecorder
def snapshot_attributes = raise NotImplementedError
def record_class = @record.class
def event_types = self.class::EVENT_TYPES
end
+8
ファイルの表示
@@ -81,6 +81,11 @@ Rails.application.routes.draw do
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
collection do
post :verify
@@ -113,5 +118,8 @@ Rails.application.routes.draw do
resources :skip_events, controller: :theatre_skip_events, only: [:index]
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]
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: true
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class AddVideoMsToPostVersions < ActiveRecord::Migration[8.0]
def change
add_column :post_versions, :video_ms, :integer
add_index :post_versions, [:video_ms, :post_id], name: 'idx_post_versions_video_ms_post_id'
add_check_constraint :post_versions, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_post_versions_video_ms_positive'
end
end
+88
ファイルの表示
@@ -0,0 +1,88 @@
class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
def up
change_table :materials, bulk: true do |t|
t.integer :version_no, null: false, default: 1
end
change_table :material_versions, bulk: true do |t|
t.string :event_type
t.string :tag_name
t.string :tag_category
t.json :export_paths_json
t.bigint :file_blob_id
t.string :file_filename
t.string :file_content_type
t.bigint :file_byte_size
t.string :file_checksum
t.string :file_sha256
end
execute <<~SQL.squish
UPDATE material_versions
SET event_type = CASE
WHEN version_no = 1 THEN 'create'
ELSE 'update'
END
WHERE event_type IS NULL
SQL
change_column_null :material_versions, :event_type, false
add_index :material_versions, :file_blob_id
add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore')",
name: 'material_versions_event_type_valid'
create_table :material_export_items do |t|
t.references :material, null: false, foreign_key: true
t.string :profile, null: false, default: 'legacy_drive'
t.string :export_path, null: false
t.boolean :enabled, null: false, default: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
t.index [:profile, :export_path], unique: true
t.index [:material_id, :profile], unique: true
end
create_table :material_import_blocks do |t|
t.string :match_kind, null: false
t.string :sha256
t.string :external_path_pattern
t.string :reason, null: false
t.text :note
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
end
execute <<~SQL.squish
UPDATE materials
SET version_no = COALESCE(
(SELECT MAX(material_versions.version_no)
FROM material_versions
WHERE material_versions.material_id = materials.id),
1)
SQL
end
def down
drop_table :material_import_blocks
drop_table :material_export_items
remove_check_constraint :material_versions, name: 'material_versions_event_type_valid'
remove_index :material_versions, :file_blob_id
remove_column :material_versions, :event_type
remove_column :material_versions, :tag_name
remove_column :material_versions, :tag_category
remove_column :material_versions, :export_paths_json
remove_column :material_versions, :file_blob_id
remove_column :material_versions, :file_filename
remove_column :material_versions, :file_content_type
remove_column :material_versions, :file_byte_size
remove_column :material_versions, :file_checksum
remove_column :material_versions, :file_sha256
remove_column :materials, :version_no
end
end
+118
ファイルの表示
@@ -0,0 +1,118 @@
class ReconcileMaterialSyncSuppressions < ActiveRecord::Migration[8.0]
MATERIAL_VERSION_EVENT_CONSTRAINT = 'material_versions_event_type_valid'
def up
ensure_material_sync_suppressions!
ensure_material_source_columns!
ensure_material_active_source_key!
remove_material_file_suppression!
remove_material_version_file_suppression!
reconcile_material_version_event_constraint!
end
def down
raise ActiveRecord::IrreversibleMigration
end
private
def ensure_material_sync_suppressions!
return if table_exists?(:material_sync_suppressions)
create_table :material_sync_suppressions do |t|
t.string :source_kind, null: false
t.string :source_uri
t.string :drive_path
t.string :drive_file_id
t.string :normalized_source_key, null: false
t.string :reason, null: false
t.references :created_by_user, foreign_key: { to_table: :users }
t.timestamps
t.index :normalized_source_key, unique: true
t.index :source_kind
t.index :drive_file_id
end
end
def ensure_material_source_columns!
add_column :materials, :source_kind, :string unless column_exists?(:materials, :source_kind)
add_column :materials, :source_uri, :string unless column_exists?(:materials, :source_uri)
add_column :materials, :source_path, :string unless column_exists?(:materials, :source_path)
unless column_exists?(:materials, :source_file_id)
add_column :materials, :source_file_id, :string
end
unless column_exists?(:materials, :normalized_source_key)
add_column :materials, :normalized_source_key, :string
end
if index_exists?(:materials, :normalized_source_key)
remove_index :materials, column: :normalized_source_key
end
end
def ensure_material_active_source_key!
unless column_exists?(:materials, :active_normalized_source_key)
change_table :materials do |t|
t.virtual :active_normalized_source_key,
type: :string,
as: 'IF(discarded_at IS NULL, normalized_source_key, NULL)',
stored: false
end
end
return if index_exists?(:materials,
:active_normalized_source_key,
name: 'index_materials_on_active_normalized_source_key')
add_index :materials,
:active_normalized_source_key,
unique: true,
name: 'index_materials_on_active_normalized_source_key'
end
def remove_material_file_suppression!
if foreign_key_exists?(:materials, :users, column: :file_suppressed_by_user_id)
remove_foreign_key :materials, column: :file_suppressed_by_user_id
end
if index_exists?(:materials,
:file_suppressed_by_user_id,
name: 'index_materials_on_file_suppressed_by_user_id')
remove_index :materials, name: 'index_materials_on_file_suppressed_by_user_id'
end
remove_column :materials, :file_suppressed_at if column_exists?(
:materials,
:file_suppressed_at)
remove_column :materials, :file_suppressed_by_user_id if column_exists?(
:materials,
:file_suppressed_by_user_id)
remove_column :materials, :file_suppression_reason if column_exists?(
:materials,
:file_suppression_reason)
end
def remove_material_version_file_suppression!
remove_column :material_versions, :file_suppressed_at if column_exists?(
:material_versions,
:file_suppressed_at)
remove_column :material_versions, :file_suppression_reason if column_exists?(
:material_versions,
:file_suppression_reason)
end
def reconcile_material_version_event_constraint!
constraint = check_constraints(:material_versions).find do |candidate|
candidate.name == MATERIAL_VERSION_EVENT_CONSTRAINT
end
return if constraint && !constraint.expression.include?('suppress')
if constraint
remove_check_constraint :material_versions, name: MATERIAL_VERSION_EVENT_CONSTRAINT
end
add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore')",
name: MATERIAL_VERSION_EVENT_CONSTRAINT
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class CreateMaterialSyncSources < ActiveRecord::Migration[8.0]
def change
create_table :material_sync_sources do |t|
t.string :name, null: false
t.string :source_kind, null: false
t.string :source_uri
t.string :source_path
t.string :source_file_id
t.string :profile, null: false, default: 'legacy_drive'
t.boolean :enabled, null: false, default: true
t.string :default_tag_name
t.string :export_path_prefix
t.datetime :last_synced_at
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.index :enabled
t.index :source_kind
end
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
class AddSourceSnapshotToMaterialVersions < ActiveRecord::Migration[8.0]
def change
unless column_exists?(:material_versions, :source_kind)
add_column :material_versions, :source_kind, :string
end
unless column_exists?(:material_versions, :source_uri)
add_column :material_versions, :source_uri, :string
end
unless column_exists?(:material_versions, :source_path)
add_column :material_versions, :source_path, :string
end
unless column_exists?(:material_versions, :source_file_id)
add_column :material_versions, :source_file_id, :string
end
unless column_exists?(:material_versions, :normalized_source_key)
add_column :material_versions, :normalized_source_key, :string
end
end
end
+171
ファイルの表示
@@ -0,0 +1,171 @@
require 'json'
class BackfillInitialMaterialVersions < ActiveRecord::Migration[8.0]
class MigrationMaterial < ActiveRecord::Base
self.table_name = 'materials'
end
class MigrationMaterialExportItem < ActiveRecord::Base
self.table_name = 'material_export_items'
end
class MigrationMaterialVersion < ActiveRecord::Base
self.table_name = 'material_versions'
end
class MigrationStorageAttachment < ActiveRecord::Base
self.table_name = 'active_storage_attachments'
end
class MigrationStorageBlob < ActiveRecord::Base
self.table_name = 'active_storage_blobs'
end
class MigrationTag < ActiveRecord::Base
self.table_name = 'tags'
end
class MigrationTagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
def up
say_with_time 'Backfilling initial material versions' do
materials_without_versions.find_in_batches(batch_size: 500) do |materials|
material_ids = materials.map(&:id)
tags_by_id = load_tags(materials.map(&:tag_id).compact.uniq)
export_paths_by_material_id = load_export_paths(material_ids)
file_snapshots_by_material_id = load_file_snapshots(material_ids)
rows = materials.map do |material|
tag = tags_by_id[material.tag_id]
file_snapshot = file_snapshots_by_material_id[material.id] || empty_file_snapshot
{ material_id: material.id,
version_no: 1,
event_type: 'create',
url: material.url,
parent_id: material.parent_id,
tag_id: material.tag_id,
tag_name: tag&.fetch(:name, nil),
tag_category: tag&.fetch(:category, nil),
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,
export_paths_json: export_paths_by_material_id[material.id] || { },
discarded_at: material.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],
created_by_user_id: material.created_by_user_id,
updated_by_user_id: material.updated_by_user_id,
created_at: material.created_at,
updated_at: material.created_at }
end
MigrationMaterialVersion.insert_all!(rows) if rows.present?
end
end
execute <<~SQL.squish
UPDATE materials
SET version_no = COALESCE(
(
SELECT MAX(material_versions.version_no)
FROM material_versions
WHERE material_versions.material_id = materials.id
),
1
)
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
private
def materials_without_versions
MigrationMaterial.where.not(id: MigrationMaterialVersion.select(:material_id))
end
def load_tags tag_ids
return { } if tag_ids.empty?
tag_name_ids_by_tag_id = MigrationTag.where(id: tag_ids).pluck(:id, :tag_name_id).to_h
tag_names_by_id =
MigrationTagName
.where(id: tag_name_ids_by_tag_id.values.uniq)
.pluck(:id, :name)
.to_h
categories_by_tag_id = MigrationTag.where(id: tag_ids).pluck(:id, :category).to_h
tag_ids.each_with_object({ }) do |tag_id, hash|
hash[tag_id] = { name: tag_names_by_id[tag_name_ids_by_tag_id[tag_id]],
category: categories_by_tag_id[tag_id] }
end
end
def load_export_paths material_ids
MigrationMaterialExportItem
.where(material_id: material_ids)
.order(:material_id, :profile)
.pluck(:material_id, :profile, :export_path)
.each_with_object({ }) do |(material_id, profile, export_path), hash|
hash[material_id] ||= { }
hash[material_id][profile] = export_path
end
end
def load_file_snapshots material_ids
attachments_by_material_id =
MigrationStorageAttachment
.where(record_type: 'Material', name: 'file', record_id: material_ids)
.pluck(:record_id, :blob_id)
.to_h
blobs_by_id =
MigrationStorageBlob
.where(id: attachments_by_material_id.values.uniq)
.index_by(&:id)
attachments_by_material_id.each_with_object({ }) do |(material_id, blob_id), hash|
blob = blobs_by_id[blob_id]
next unless blob
hash[material_id] = {
file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob_metadata(blob)['sha256']
}
end
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 blob_metadata blob
metadata = blob.metadata
return metadata if metadata.is_a?(Hash)
return { } if metadata.blank?
JSON.parse(metadata)
rescue JSON::ParserError
{ }
end
end
+31
ファイルの表示
@@ -0,0 +1,31 @@
class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users)
remove_index :settings, :user_id if index_exists?(:settings, :user_id)
remove_column :settings, :key, :string if column_exists?(:settings, :key)
remove_column :settings, :value, :json if column_exists?(:settings, :value)
change_column_null :settings, :user_id, false
add_column :settings, :theme, :string, null: false, default: 'system'
add_column :settings,
:auto_fetch_title,
:string,
null: false,
default: 'manual'
add_column :settings,
:auto_fetch_thumbnail,
:string,
null: false,
default: 'manual'
add_column :settings,
:wiki_editor_mode,
:string,
null: false,
default: 'split'
add_index :settings, :user_id, unique: true
add_foreign_key :settings, :users
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class CreateUserThemeSlots < ActiveRecord::Migration[8.0]
def change
create_table :user_theme_slots do |t|
t.references :user, null: false, foreign_key: true
t.string :base_theme, null: false
t.integer :slot_no, null: false
t.json :tokens, null: false
t.timestamps
end
add_index :user_theme_slots,
[:user_id, :base_theme, :slot_no],
unique: true,
name: 'index_user_theme_slots_on_user_theme_and_slot'
add_check_constraint :user_theme_slots,
"base_theme IN ('light', 'dark')",
name: 'user_theme_slots_base_theme_valid'
add_check_constraint :user_theme_slots,
'slot_no BETWEEN 1 AND 3',
name: 'user_theme_slots_slot_no_valid'
end
end
生成ファイル
+150 -19
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -130,6 +130,69 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false
t.string "export_path", null: false
t.boolean "enabled", default: true, null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id"
t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true
t.index ["material_id"], name: "index_material_export_items_on_material_id"
t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true
end
create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "match_kind", null: false
t.string "sha256"
t.string "external_path_pattern"
t.string "reason", null: false
t.text "note"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id"
end
create_table "material_sync_sources", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "source_kind", null: false
t.string "source_uri"
t.string "source_path"
t.string "source_file_id"
t.string "profile", default: "legacy_drive", null: false
t.boolean "enabled", default: true, null: false
t.string "default_tag_name"
t.string "export_path_prefix"
t.datetime "last_synced_at"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_sync_sources_on_created_by_user_id"
t.index ["enabled"], name: "index_material_sync_sources_on_enabled"
t.index ["source_kind"], name: "index_material_sync_sources_on_source_kind"
t.index ["updated_by_user_id"], name: "index_material_sync_sources_on_updated_by_user_id"
end
create_table "material_sync_suppressions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "source_kind", null: false
t.string "source_uri"
t.string "drive_path"
t.string "drive_file_id"
t.string "normalized_source_key", null: false
t.string "reason", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_material_sync_suppressions_on_created_by_user_id"
t.index ["drive_file_id"], name: "index_material_sync_suppressions_on_drive_file_id"
t.index ["normalized_source_key"], name: "index_material_sync_suppressions_on_normalized_source_key", unique: true
t.index ["source_kind"], name: "index_material_sync_suppressions_on_source_kind"
end
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.integer "version_no", null: false
@@ -141,14 +204,31 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.string "event_type", null: false
t.string "tag_name"
t.string "tag_category"
t.json "export_paths_json"
t.bigint "file_blob_id"
t.string "file_filename"
t.string "file_content_type"
t.bigint "file_byte_size"
t.string "file_checksum"
t.string "file_sha256"
t.string "source_kind"
t.string "source_uri"
t.string "source_path"
t.string "source_file_id"
t.string "normalized_source_key"
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "material_versions_event_type_valid"
end
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -161,6 +241,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.integer "version_no", default: 1, null: false
t.string "source_kind"
t.string "source_uri"
t.string "source_path"
t.string "source_file_id"
t.string "normalized_source_key"
t.virtual "active_normalized_source_key", type: :string, as: "if((`discarded_at` is null),`normalized_source_key`,NULL)"
t.index ["active_normalized_source_key"], name: "index_materials_on_active_normalized_source_key", unique: true
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
@@ -210,6 +298,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "(`end_ms` is null) or (`begin_ms` < `end_ms`)", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
@@ -243,9 +344,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "original_created_before"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.integer "video_ms"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end
@@ -260,18 +364,36 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_settings_on_user_id"
t.string "theme", default: "system", null: false
t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -319,9 +441,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
@@ -334,10 +456,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
@@ -459,6 +581,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["post_id"], name: "index_user_post_views_on_post_id"
end
create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "base_theme", null: false
t.integer "slot_no", null: false
t.json "tokens", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true
t.index ["user_id"], name: "index_user_theme_slots_on_user_id"
t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid"
t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name"
t.string "inheritance_code", limit: 64, null: false
@@ -469,19 +604,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -567,6 +689,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
add_foreign_key "material_sync_sources", "users", column: "created_by_user_id"
add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id"
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
@@ -584,6 +712,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
@@ -623,6 +753,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
+18
ファイルの表示
@@ -7,3 +7,21 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# end
material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI']
material_sync_source_file_id = ENV['MATERIAL_SYNC_SOURCE_FILE_ID']
if material_sync_source_uri.present? || material_sync_source_file_id.present?
source = MaterialSyncSource.find_or_initialize_by(
name: ENV.fetch('MATERIAL_SYNC_SOURCE_NAME', 'Legacy URI material sync'))
source.assign_attributes(
source_kind: ENV.fetch('MATERIAL_SYNC_SOURCE_KIND', 'uri'),
source_uri: material_sync_source_uri,
source_path: ENV['MATERIAL_SYNC_SOURCE_PATH'],
source_file_id: material_sync_source_file_id,
profile: ENV.fetch('MATERIAL_SYNC_SOURCE_PROFILE', 'legacy_drive'),
enabled: ENV.fetch('MATERIAL_SYNC_SOURCE_ENABLED', 'true') != 'false',
default_tag_name: ENV['MATERIAL_SYNC_SOURCE_TAG_NAME'],
export_path_prefix: ENV['MATERIAL_SYNC_EXPORT_PATH_PREFIX'])
source.save!
end
+51
ファイルの表示
@@ -0,0 +1,51 @@
namespace :materials do
desc '素材同期'
task sync: :environment do
result = MaterialSyncRunner.sync_enabled!
message = [
"materials:sync imported=#{ result.imported }",
"updated=#{ result.updated }",
"unchanged=#{ result.unchanged }",
"suppressed=#{ result.suppressed }",
"failed=#{ result.failed }"
].join(' ')
Rails.logger.info(message)
puts message
result.errors.each do |error|
Rails.logger.warn(error.to_json)
warn error.to_json
end
end
namespace :thumbnails do
desc '素材サムネールを補完'
task backfill: :environment do
counts = Hash.new(0)
Material
.with_attached_file
.with_attached_thumbnail
.find_each do |material|
next unless material.file.attached?
next if material.thumbnail.attached?
result = MaterialThumbnailGenerator.generate!(material)
counts[result] += 1
end
message =
"materials:thumbnails:backfill " \
"attached=#{ counts[:attached] } " \
"no_file=#{ counts[:no_file] } " \
"unsupported_content_type=#{ counts[:unsupported_content_type] } " \
"generation_failed=#{ counts[:generation_failed] } " \
"attach_failed=#{ counts[:attach_failed] } " \
"file_not_found=#{ counts[:file_not_found] } " \
"mini_magick_error=#{ counts[:mini_magick_error] }"
Rails.logger.info(message)
puts message
end
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
+57
ファイルの表示
@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:material) do
Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user)
end
it 'rejects blank export_path' do
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects absolute export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '/素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects parent traversal export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/../a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects double slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材//a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects dot segment export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: './素材/a.png')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
it 'rejects trailing slash export_path' do
item = described_class.new(material:, profile: 'legacy_drive',
export_path: '素材/a/')
expect(item).not_to be_valid
expect(item.errors[:export_path]).to be_present
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Material, type: :model do
let(:user) { create(:user, :member) }
it 'allows only material source kinds, not suppression prefix kinds' do
material = described_class.new(url: 'https://example.com/material',
source_kind: 'google_drive_path_prefix',
source_path: '素材/危険',
created_by_user: user,
updated_by_user: user)
expect(material).not_to be_valid
expect(material.errors[:source_kind]).to be_present
material.source_kind = 'google_drive_path'
expect(material).to be_valid
end
end
+41
ファイルの表示
@@ -0,0 +1,41 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
it 'allows open-ended sections' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: nil)
expect(section).to be_valid
expect(post_tag.sections).to contain_exactly(section)
end
end
end
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe Setting, type: :model do
it 'accepts the default typed user settings' do
setting = described_class.new({ user: create(:user) }.merge(described_class.defaults))
expect(setting).to be_valid
end
it 'requires one settings row per user' do
user = create(:user)
described_class.create!({ user: }.merge(described_class.defaults))
duplicate = described_class.new({ user: }.merge(described_class.defaults))
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates enum-like settings columns' do
setting = described_class.new(
{ user: create(:user),
theme: 'neon',
auto_fetch_title: 'sometimes',
auto_fetch_thumbnail: 'sometimes',
wiki_editor_mode: 'sideways' })
expect(setting).not_to be_valid
expect(setting.errors[:theme]).to be_present
expect(setting.errors[:auto_fetch_title]).to be_present
expect(setting.errors[:auto_fetch_thumbnail]).to be_present
expect(setting.errors[:wiki_editor_mode]).to be_present
end
end
+36
ファイルの表示
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
implication = described_class.new(tag: parent, parent_tag: child)
expect(implication).not_to be_valid
expect(implication.errors[:parent_tag_id]).to include(
'親タグに子孫タグを指定すると循環します.'
)
expect(implication.errors[:base]).to be_present
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
[
{ tag_id: ancestor.id, parent_tag_id: parent.id,
created_at: Time.current, updated_at: Time.current }
]
)
implication = described_class.new(tag: child, parent_tag: parent)
expect(implication).to be_valid
end
end
+100 -1
ファイルの表示
@@ -19,6 +19,95 @@ RSpec.describe Tag, type: :model do
expect(error.tag_names).to eq([deprecated_tag.name])
}
end
it 'rejects invalid section literals instead of treating them as zero' do
expect {
described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true
)
}.to raise_error(Tag::SectionLiteralParseError)
end
it 'parses open-ended section literals' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end
it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end
it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end
it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
end
describe '.expand_parent_tags' do
@@ -54,7 +143,17 @@ RSpec.describe Tag, type: :model do
first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second)
TagImplication.create!(tag: second, parent_tag: first)
now = Time.current
TagImplication.insert_all!(
[
{
tag_id: second.id,
parent_tag_id: first.id,
created_at: now,
updated_at: now
}
]
)
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe UserThemeSlot, type: :model do
it 'accepts a theme slot for one user, base theme, and slot number' do
slot = described_class.new(user: create(:user),
base_theme: 'light',
slot_no: 1,
tokens: { 'background' => '0 0% 100%' })
expect(slot).to be_valid
end
it 'requires unique slot numbers per user and base theme' do
user = create(:user)
described_class.create!(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '222.2 84% 4.9%' })
duplicate = described_class.new(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '0 0% 100%' })
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates base theme, slot number, and token object shape' do
slot = described_class.new(user: create(:user),
base_theme: 'system',
slot_no: 4,
tokens: 'not-object')
expect(slot).not_to be_valid
expect(slot.errors[:base_theme]).to be_present
expect(slot.errors[:slot_no]).to be_present
expect(slot.errors[:tokens]).to be_present
end
end
+377 -34
ファイルの表示
@@ -1,7 +1,10 @@
require 'rails_helper'
RSpec.describe 'Materials API', type: :request do
include ActiveJob::TestHelper
let!(:member_user) { create(:user, :member) }
let!(:admin_user) { create(:user, :admin) }
let!(:guest_user) { create(:user) }
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
end
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
Material.new(tag:, parent:, url:,
created_by_user: user,
updated_by_user: user).tap do |material|
material.file.attach(file) if file
material.save!
end
end
describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
let!(:tag_a) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
end
let!(:tag_b) do
Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
end
let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end
let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
build_material(tag: tag_b, user: member_user, parent: material_a,
file: dummy_upload(filename: 'b.png'))
end
before do
@@ -54,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do
'name' => 'material_index_b',
'category' => 'material'
)
expect(row['created_by_user']).to include(
'id' => member_user.id,
'name' => member_user.name
)
expect(row['content_type']).to eq('image/png')
expect(row['media_kind']).to eq('image')
end
it 'filters materials by tag_id' do
get '/materials', params: { tag_id: material_a.tag_id }
it 'filters materials by q' do
get '/materials', params: { q: 'material_index_a' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end
it 'filters materials by parent_id' do
get '/materials', params: { parent_id: material_a.id }
it 'filters materials by tag_state' do
untagged =
build_material(tag: nil, user: member_user,
file: dummy_upload(filename: 'untagged.png'))
get '/materials', params: { tag_state: 'untagged' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
expect(response_materials.map { |m| m['id'] }).to eq([untagged.id])
end
it 'filters materials by media_kind' do
get '/materials', params: { media_kind: 'image' }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image'))
end
it 'paginates and keeps total count' do
@@ -94,10 +113,74 @@ RSpec.describe 'Materials API', type: :request do
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id)
end
it 'filters by descendant tags and returns stable parent tag groups' do
root =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'),
category: :material)
child_b =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'),
category: :material)
child_a =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'),
category: :material)
deprecated =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'),
category: :material,
deprecated_at: Time.current)
grandchild =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'),
category: :material)
root_material =
build_material(tag: root, user: member_user,
file: dummy_upload(filename: 'root.png'))
child_a_material =
build_material(tag: child_a, user: member_user,
file: dummy_upload(filename: 'child_a.png'))
grandchild_material =
build_material(tag: grandchild, user: member_user,
file: dummy_upload(filename: 'grandchild.png'))
TagImplication.create!(parent_tag: root, tag: child_b)
TagImplication.create!(parent_tag: root, tag: child_a)
TagImplication.create!(parent_tag: child_b, tag: deprecated)
TagImplication.create!(parent_tag: deprecated, tag: grandchild)
get '/materials',
params: { tag_id: root.id, include_descendants: '1',
group_by: 'parent_tag', sort: 'id', direction: 'asc' }
expect(response).to have_http_status(:ok)
expect(response_materials.map { |m| m['id'] })
.to include(root_material.id, child_a_material.id, grandchild_material.id)
expect(json['tag_scope']).to eq(
'tag' => {
'id' => root.id,
'name' => 'material_scope_root',
'category' => 'material'
},
'include_descendants' => true
)
expect(json['groups'].map { |group| group.dig('tag', 'name') })
.to eq(['material_scope_a', 'material_scope_b', 'material_scope_root'])
expect(json['groups'].find { |group| group.dig('tag', 'id') == child_b.id }
.fetch('material_ids')).to eq([grandchild_material.id])
end
it 'returns nil tag_scope when tag_id is unknown' do
get '/materials', params: { tag_id: 999_999, group_by: 'parent_tag' }
expect(response).to have_http_status(:ok)
expect(json['tag_scope']).to be_nil
expect(json['groups']).to eq([])
expect(response_materials).to eq([])
end
end
describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end
@@ -138,9 +221,22 @@ RSpec.describe 'Materials API', type: :request do
end
end
context 'when logged in' do
context 'when logged in but not member' do
before { sign_in_as(guest_user) }
it 'returns 403' do
post '/materials', params: {
tag: 'material_create_guest_forbidden',
file: dummy_upload
}
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before { sign_in_as(member_user) }
it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }
@@ -162,24 +258,49 @@ RSpec.describe 'Materials API', type: :request do
expect do
post '/materials', params: {
tag: 'material_create_new',
file: dummy_upload(filename: 'created.png')
file: dummy_upload(filename: 'created.png'),
export_paths: { legacy_drive: '伊地知ニジカ/created.png' }
}
end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user)
expect(material.updated_by_user).to eq(guest_user)
expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
expect(material.version_no).to eq(1)
expect(material.material_versions.first.event_type).to eq('create')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
expect(material.material_versions.first.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/created.png'
)
expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png')
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
file: dummy_upload(filename: 'created.png', body: 'sha-body')
}
expect(response).to have_http_status(:created)
version = Material.order(:id).last.material_versions.first
expect(version.file_blob_id).to be_present
expect(version.file_filename).to eq('created.png')
expect(version.file_content_type).to eq('image/png')
expect(version.file_byte_size).to eq('sha-body'.bytesize)
expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body'))
end
it 'returns 422 when the existing tag is not material/character' do
@@ -219,11 +340,33 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source')
end
it 'rejects sha256-blocked file upload' do
sha256 = Digest::SHA256.hexdigest('blocked-body')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'copyright_high_risk',
created_by_user: admin_user)
expect do
post '/materials', params: {
tag: 'material_blocked_create',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-body')
}
end.not_to change(Material, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['抑止された素材です: copyright_high_risk']
)
end
end
end
describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end
@@ -277,25 +420,26 @@ RSpec.describe 'Materials API', type: :request do
'tag' => ['タグは必須です.'])
end
it 'returns 422 when both file and url are blank' do
it 'keeps the existing file when file and url are omitted' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
expect(response).to have_http_status(:ok)
expect(material.reload.file.attached?).to be(true)
end
it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
}
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'),
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
}
end.to change(MaterialVersion, :count).by(2)
expect(response).to have_http_status(:ok)
@@ -306,8 +450,15 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg')
expect(material.version_no).to eq(2)
expect(material.material_versions.order(:version_no).last.event_type).to eq('update')
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '伊地知ニジカ/updated.jpg'
)
expect(json['id']).to eq(material.id)
expect(json['file']).to be_present
@@ -315,7 +466,7 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new')
end
it 'purges the existing file when file is omitted and url is provided' do
it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: {
@@ -331,9 +482,7 @@ RSpec.describe 'Materials API', type: :request do
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false)
expect(
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil
@@ -341,11 +490,205 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source')
end
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old'
}
end.not_to change(MaterialVersion, :count)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
it 'records update version when only export_path changes' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '素材/only-path.png' }
}
end.to change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png')
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq(
'legacy_drive' => '素材/only-path.png'
)
end
it 'removes export_path item when blank is submitted' do
MaterialExportItem.create!(material:, profile: 'legacy_drive',
export_path: '素材/remove.png',
created_by_user: member_user)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
export_paths: { legacy_drive: '' }
}
end.to change(MaterialExportItem, :count).by(-1)
.and change(MaterialVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(material.reload.material_export_items).to be_empty
expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({})
end
it 'rejects sha256-blocked replacement file' do
sha256 = Digest::SHA256.hexdigest('blocked-update')
MaterialImportBlock.create!(match_kind: 'sha256',
sha256:,
reason: 'source_owner_request',
created_by_user: admin_user)
put "/materials/#{ material.id }", params: {
tag: 'material_update_old',
file: dummy_upload(filename: 'blocked.png', body: 'blocked-update')
}
expect(response).to have_http_status(:unprocessable_entity)
expect(material.reload.file.blob.filename.to_s).to eq('old.png')
end
end
end
describe 'GET /materials/download.zip' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
let!(:material_a) do
build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a'))
end
let!(:material_b) do
build_material(tag: tag_b, user: member_user,
file: dummy_upload(filename: 'b.png', body: 'zip-b'))
end
before do
MaterialExportItem.create!(material: material_a, profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: member_user)
MaterialExportItem.create!(material: material_b, profile: 'legacy_drive',
export_path: '素材/b.png',
created_by_user: member_user)
end
it 'uses material_export_items.export_path as ZIP entry paths' do
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/zip')
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).to include('素材/b.png'.b)
end
it 'filters by tag_id' do
get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
it 'does not include disabled export items' do
material_b.material_export_items.first.update!(enabled: false)
get '/materials/download.zip', params: { profile: 'legacy_drive' }
expect(response).to have_http_status(:ok)
expect(response.body.b).to include('素材/a.png'.b)
expect(response.body.b).not_to include('素材/b.png'.b)
end
end
describe 'GET /materials/versions' do
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
end
it 'returns material versions in reverse chronological order' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
material.update!(url: 'https://example.com/history')
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: admin_user)
get '/materials/versions', params: { material_id: material.id }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
versions = json.fetch('versions')
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
expect(versions.first).to include(
'material_id' => material.id,
'event_type' => 'update',
'tag_name' => 'material_history',
'file_filename' => 'history.png',
'url' => 'https://example.com/history'
)
expect(versions.first['created_by_user']).to include('id' => admin_user.id)
end
it 'filters material versions by tag and event_type' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
get '/materials/versions', params: {
tag: 'material_history',
event_type: 'create'
}
expect(response).to have_http_status(:ok)
expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id)
expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create'))
end
end
describe 'material sync suppressions' do
it 'requires member permission for index' do
sign_out
get '/materials/suppressions'
expect(response).to have_http_status(:unauthorized)
end
it 'creates source suppression as member' do
sign_in_as(member_user)
expect do
post '/materials/suppressions', params: {
source_kind: 'google_drive_path_prefix',
drive_path: '伊地知ニジカ/危険素材',
reason: 'copyright_high_risk'
}
end.to change(MaterialSyncSuppression, :count).by(1)
expect(response).to have_http_status(:created)
expect(json).to include(
'source_kind' => 'google_drive_path_prefix',
'drive_path' => '伊地知ニジカ/危険素材',
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
)
end
end
describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
end
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end
+287
ファイルの表示
@@ -1,4 +1,5 @@
require 'rails_helper'
require 'base64'
require 'set'
include ActiveSupport::Testing::TimeHelpers
@@ -8,6 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
end
def create_nico_tag!(name)
@@ -19,6 +25,18 @@ RSpec.describe 'Posts API', type: :request do
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end
def real_thumbnail_upload
gif =
Base64.decode64(
'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
Rack::Test::UploadedFile.new(
StringIO.new(gif),
'image/gif',
original_filename: 'thumbnail.gif'
)
end
def post_write_params params = { }
{ parent_post_ids: '' }.merge(params)
end
@@ -36,6 +54,7 @@ RSpec.describe 'Posts API', type: :request do
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
@@ -127,6 +146,22 @@ RSpec.describe 'Posts API', type: :request do
expect(all_tag_names).to include("spec_tag")
end
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 1_000, 'end_ms' => nil }
])
end
context "when q is provided" do
it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" }
@@ -696,6 +731,66 @@ RSpec.describe 'Posts API', type: :request do
expect(json['tags'][0]).to have_key('name')
end
it '201 when posting manually with a thumbnail' do
sign_in_as(member)
allow(Post).to receive(:resized_thumbnail_attachment).and_call_original
post '/posts', params: post_write_params(
title: 'thumbnail post',
url: 'https://example.com/thumbnail-post',
tags: 'spec_tag',
thumbnail: real_thumbnail_upload
)
expect(response).to have_http_status(:created)
post_record = Post.find(json.fetch('id'))
expect(post_record.thumbnail).to be_attached
expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
expect { post_record.thumbnail.download }.not_to raise_error
end
it 'resizes the thumbnail before the create transaction begins' do
sign_in_as(member)
open_transactions = []
baseline_open_transactions = Post.connection.open_transactions
allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
open_transactions << Post.connection.open_transactions
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
post '/posts', params: post_write_params(
title: 'transaction post',
url: 'https://example.com/transaction-post',
tags: 'spec_tag',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
sign_in_as(member)
allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)
expect {
post '/posts', params: post_write_params(
title: 'broken thumbnail post',
url: 'https://example.com/broken-thumbnail-post',
tags: 'spec_tag',
thumbnail: dummy_upload
)
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
)
end
it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member)
@@ -767,6 +862,198 @@ RSpec.describe 'Posts API', type: :request do
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end
it 'returns validation error for an invalid section literal' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid section literal',
url: 'https://example.com/invalid-section-literal',
tags: 'spec_tag[1:aa-2:00]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.'
)
expect(json.fetch('errors')).to include(
'tags' => ['タグ区間の記法が不正です.']
)
end
it 'creates a video post with duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post',
url: 'https://example.com/video-post',
tags: '動画 spec_tag',
duration: '3:00.500',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'creates a video post with number input duration seconds' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post seconds',
url: 'https://example.com/video-post-seconds',
tags: '動画 spec_tag',
duration: '180.5',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'clears video_ms when the saved tags do not include 動画' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non video post',
url: 'https://example.com/non-video-post',
tags: 'spec_tag',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to be_nil
end
it 'returns validation error when a bounded section exceeds duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'too long section',
url: 'https://example.com/too-long-section',
tags: '動画 伊地知ニジカ[2:50-3:10]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.']
)
end
it 'saves open-ended sections with end_ms NULL' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'open ended section literal',
url: 'https://example.com/open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
section = PostTagSection.find_by!(post: created_post, tag:)
expect(section.begin_ms).to eq(60_000)
expect(section.end_ms).to be_nil
end
it 'does not save sections for [-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'fully open section literal',
url: 'https://example.com/fully-open-section-literal',
tags: '伊地知ニジカ[-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'does not save sections for [0:00-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'zero open ended section literal',
url: 'https://example.com/zero-open-ended-section-literal',
tags: '伊地知ニジカ[0:00-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'returns end_ms null for open-ended sections in show response' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'show open ended section literal',
url: 'https://example.com/show-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
get "/posts/#{ json.fetch('id') }"
expect(response).to have_http_status(:ok)
tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' }
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 60_000, 'end_ms' => nil }
])
end
it 'allows open-ended sections when begin is within duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid open ended section literal',
url: 'https://example.com/valid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
end
it 'rejects open-ended sections when begin equals duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid open ended section literal',
url: 'https://example.com/invalid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[3:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の開始が動画時間以上です.']
)
end
context "when nico tag already exists in tags" do
before do
Tag.find_undiscard_or_create_by!(
+11
ファイルの表示
@@ -56,6 +56,17 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content)
end
it 'returns 422 and does not create relation when the new link makes a cycle' do
TagImplication.create!(tag: parent, parent_tag: child)
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
end
end
context "when Tag.find raises (invalid ids)" do
+183 -3
ファイルの表示
@@ -581,6 +581,58 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
end
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
put "/tags/#{ tag.id }", params: {
name: 'patch_roundtrip_target',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
expect(response).to have_http_status(:ok)
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name)
patch "/tags/#{ tag.id }", params: { name: 'spec_tag' }
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq('spec_tag')
expect(tag.tag_name.canonical_id).to be_nil
expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name)
end
it '別 tag の正規名には変更できない' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'patch collision wiki',
created_by_user: member_user,
message: 'init')
patch "/tags/#{ tag.id }", params: { name: 'unknown' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['その名前は既に使はれてゐます.']
)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko')
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
end
it 'system tag の name は変更できない' do
system_tag = Tag.bot
patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' }
expect(response).to have_http_status(:unprocessable_entity)
expect(system_tag.reload.name).to eq('bot操作')
end
end
end
@@ -745,7 +797,17 @@ RSpec.describe 'Tags API', type: :request do
)
TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first)
TagImplication.create!(tag: first, parent_tag: second)
now = Time.current
TagImplication.insert_all!(
[
{
tag_id: first.id,
parent_tag_id: second.id,
created_at: now,
updated_at: now
}
]
)
get '/tags/with-depth', params: { parent: root_material.id }
@@ -1029,6 +1091,42 @@ RSpec.describe 'Tags API', type: :request do
expect(versions.second.created_by_user_id).to eq(member_user.id)
end
it '同じ tag の旧 alias へ戻しても rename できる' do
put "/tags/#{ tag.id }", params: {
name: 'put_roundtrip_b',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
expect(response).to have_http_status(:ok)
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq('spec_tag')
expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name)
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko')
expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag')
expect(alias_tn.reload.canonical).to eq(tag.tag_name)
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('update')
expect(version.name).to eq('spec_tag')
expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko')
end
it 'parent tag の snapshot も作成する' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
@@ -1153,6 +1251,48 @@ RSpec.describe 'Tags API', type: :request do
)
end
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_roundtrip_b',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
}
.to change(TagVersion, :count).by(3)
.and change(WikiVersion, :count).by(2)
expect(response).to have_http_status(:ok)
tag.reload
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name)
versions = wiki_page.wiki_versions.order(:version_no).last(2)
expect(versions.map(&:event_type)).to eq(['update', 'update'])
expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_old_owner'),
@@ -1191,9 +1331,49 @@ RSpec.describe 'Tags API', type: :request do
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
it '別 tag の alias 名を rename で奪へる' do
old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_collision_owner'),
category: :general
)
stolen_alias = TagName.create!(
name: 'put_alias_collision_name',
canonical: old_owner.tag_name
)
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'put collision wiki',
created_by_user: member_user,
message: 'init')
put "/tags/#{ tag.id }", params: {
name: 'put_alias_collision_name',
category: 'general',
aliases: 'unko',
parent_tags: '',
deprecated: '0',
}
expect(response).to have_http_status(:ok)
tag.reload
old_owner.reload
stolen_alias.reload
expect(tag.name).to eq('put_alias_collision_name')
expect(stolen_alias.canonical_id).to be_nil
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name)
expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name')
old_owner_versions = old_owner.tag_versions.order(:version_no)
expect(old_owner_versions.last.event_type).to eq('update')
expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name')
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general
+98
ファイルの表示
@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe 'user settings', type: :request do
describe 'GET /users/settings' do
it 'requires a current user' do
sign_out
get '/users/settings'
expect(response).to have_http_status(:unauthorized)
end
it 'returns defaults and creates a missing settings row' do
user = create(:user)
sign_in_as(user)
expect {
get '/users/settings'
}.to change(Setting, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to eq(
'theme' => 'system',
'auto_fetch_title' => 'manual',
'auto_fetch_thumbnail' => 'manual',
'wiki_editor_mode' => 'split')
expect(user.reload.setting).to be_present
end
end
describe 'PATCH /users/settings' do
it 'requires a current user' do
sign_out
patch '/users/settings', params: { theme: 'dark' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates multiple typed settings at once' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: {
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview' },
as: :json
expect(response).to have_http_status(:ok)
expect(json).to include(
'theme' => 'dark',
'auto_fetch_title' => 'auto',
'auto_fetch_thumbnail' => 'off',
'wiki_editor_mode' => 'preview')
expect(user.reload.setting).to have_attributes(
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview')
end
it 'does not treat Rails parameter wrapping as an editable setting' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: { theme: 'light', user_setting: { theme: 'dark' } },
as: :json
expect(response).to have_http_status(:ok)
expect(user.reload.setting.theme).to eq('light')
end
it 'returns validation_error for type mismatches' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 1 }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors')).to include(
'theme' => ['値の型が不正です.'])
end
it 'returns validation_error for invalid enum values' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 'neon' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors').fetch('theme')).to be_present
end
end
end
+104
ファイルの表示
@@ -0,0 +1,104 @@
require 'rails_helper'
RSpec.describe 'user theme slots', type: :request do
describe 'GET /users/theme_slots' do
it 'requires a current user' do
sign_out
get '/users/theme_slots'
expect(response).to have_http_status(:unauthorized)
end
it 'returns only the current user slots in stable order' do
user = create(:user)
other_user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 })
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 })
UserThemeSlot.create!(user: other_user,
base_theme: 'dark',
slot_no: 3,
tokens: { 'c' => 3 })
get '/users/theme_slots'
expect(response).to have_http_status(:ok)
expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq(
[['dark', 1], ['light', 2]])
expect(json.map { |slot| slot.fetch('tokens') }).to eq(
[{ 'b' => 2 }, { 'a' => 1 }])
end
end
describe 'PUT /users/theme_slots/:base_theme/:slot_no' do
it 'requires a current user' do
sign_out
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%' } },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a theme slot' do
user = create(:user)
sign_in_as(user)
expect {
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } },
as: :json
}.to change(UserThemeSlot, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to include('base_theme' => 'light', 'slot_no' => 1)
expect(json.fetch('tokens')).to include(
'background' => '0 0% 100%',
'tagColours' => { 'nico' => '#ffffff' })
expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include(
'background' => '0 0% 100%')
end
it 'updates an existing theme slot instead of creating a duplicate' do
user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true })
expect {
put '/users/theme_slots/dark/2',
params: { tokens: { background: '222.2 84% 4.9%' } },
as: :json
}.not_to change(UserThemeSlot, :count)
expect(response).to have_http_status(:ok)
expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq(
'background' => '222.2 84% 4.9%')
end
it 'rejects invalid path parameters and token shapes' do
sign_in_as(create(:user))
put '/users/theme_slots/system/4',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'base_theme' => ['値が不正です.'])
end
it 'rejects non-object tokens for valid slots' do
sign_in_as(create(:user))
put '/users/theme_slots/light/1',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tokens' => ['JSON object で指定してください.'])
end
end
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe MaterialFileSha256 do
describe '.metadata_sha256' do
it 'reads sha256 from Hash metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: { 'sha256' => 'abc123' })
expect(described_class.metadata_sha256(blob)).to eq('abc123')
end
it 'reads sha256 from JSON string metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: '{"sha256":"abc123"}')
expect(described_class.metadata_sha256(blob)).to eq('abc123')
end
it 'returns nil for invalid metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: '{')
expect(described_class.metadata_sha256(blob)).to be_nil
end
end
end
+128
ファイルの表示
@@ -0,0 +1,128 @@
require 'rails_helper'
RSpec.describe MaterialSyncImporter do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) }
def tempfile_for body
Tempfile.new(['material-sync-importer', '.png']).tap do |file|
file.binmode
file.write(body)
file.rewind
end
end
it 'creates an untagged material from a sync candidate with file_downloader' do
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
normalized_source_key: 'google_drive_file:drive-file-a',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('sync-body'),
file_downloader: -> { tempfile_for('sync-body') },
export_path: '素材/a.png',
tag: nil,
url: nil,
created_by_user: user,
updated_by_user: user)
material = result.material.reload
expect(result.action).to eq(:imported)
expect(material.tag).to be_nil
expect(material.url).to be_nil
expect(material.file).to be_attached
expect(material.file.blob.filename.to_s).to eq('a.png')
expect(material.material_export_items.first.export_path).to eq('素材/a.png')
end
it 'does not clear a human-assigned tag or URL on existing Drive sync' do
material =
Material.create!(tag:, url: 'https://example.com/human',
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
created_by_user: user,
updated_by_user: user)
material.file.attach(
io: StringIO.new('same-body'),
filename: 'a.png',
content_type: 'image/png')
material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body')
material.file.blob.save!
MaterialExportItem.create!(material:,
profile: 'legacy_drive',
export_path: '素材/a.png',
created_by_user: user)
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/a.png',
source_file_id: 'drive-file-a',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('same-body'),
export_path: '素材/a.png',
tag: nil,
url: nil,
updated_by_user: user)
expect(result.action).to eq(:unchanged)
expect(material.reload.tag).to eq(tag)
expect(material.url).to eq('https://example.com/human')
end
it 'suppresses a Google Drive candidate by relative path prefix' do
MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix',
drive_path: '素材/危険',
reason: 'copyright_high_risk',
created_by_user: user)
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/危険/a.png',
source_file_id: 'drive-file-b',
filename: 'a.png',
content_type: 'image/png',
file_sha256: Digest::SHA256.hexdigest('blocked-body'),
file_downloader: -> { tempfile_for('blocked-body') },
export_path: '素材/危険/a.png',
updated_by_user: user)
expect(result.action).to eq(:suppressed)
expect(result.suppression.normalized_source_key)
.to eq('google_drive_path_prefix:素材/危険')
expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil
end
it 'rechecks import blocks after downloaded file sha256 is known' do
MaterialImportBlock.create!(match_kind: 'sha256',
sha256: Digest::SHA256.hexdigest('blocked-body'),
reason: 'copyright_high_risk',
created_by_user: user)
blob_count = ActiveStorage::Blob.count
expect do
result =
described_class.import!(
source_kind: 'google_drive_file',
source_path: '素材/blocked.png',
source_file_id: 'drive-file-blocked',
filename: 'blocked.png',
content_type: 'image/png',
file_downloader: -> { tempfile_for('blocked-body') },
export_path: '素材/blocked.png',
updated_by_user: user)
expect(result.action).to eq(:suppressed)
expect(result.suppression.reason).to eq('copyright_high_risk')
end.not_to change(Material, :count)
expect(ActiveStorage::Blob.count).to eq(blob_count)
expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil
end
end
+89
ファイルの表示
@@ -0,0 +1,89 @@
require 'rails_helper'
RSpec.describe MaterialSyncRunner do
let(:user) { create(:user, :member) }
let(:source) do
MaterialSyncSource.create!(
name: 'Drive source',
source_kind: 'google_drive_path',
source_file_id: 'folder-123',
profile: 'legacy_drive',
created_by_user: user,
updated_by_user: user)
end
let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
describe '#sync!' do
it 'imports google drive path candidates as they are yielded' do
first_entry = { id: 'file-1',
name: 'a.png',
mime_type: 'image/png',
relative_path: '素材/a.png',
sha256_checksum: 'sha-a',
web_view_link: 'https://drive.google.com/file/d/file-1/view',
web_content_link: nil }
second_entry = { id: 'file-2',
name: 'b.png',
mime_type: 'image/png',
relative_path: '素材/b.png',
sha256_checksum: 'sha-b',
web_view_link: 'https://drive.google.com/file/d/file-2/view',
web_content_link: nil }
yielded = []
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block|
expect(folder_id).to eq('folder-123')
block.call(first_entry)
expect(yielded).to eq(['素材/a.png'])
block.call(second_entry)
end
allow(MaterialSyncImporter).to receive(:import!) do |candidate|
yielded << candidate.fetch(:source_path)
instance_double(MaterialSyncImporter::Result,
action: yielded.last == '素材/a.png' ? :imported : :updated)
end
result = described_class.new(source).sync!
expect(yielded).to eq(['素材/a.png', '素材/b.png'])
expect(result.imported).to eq(1)
expect(result.updated).to eq(1)
expect(result.unchanged).to eq(0)
expect(source.reload.last_synced_at).to be_present
end
it 'logs google drive path progress at first item and summary' do
entry = { id: 'file-1',
name: 'a.png',
mime_type: 'image/png',
relative_path: '素材/a.png',
sha256_checksum: 'sha-a',
web_view_link: 'https://drive.google.com/file/d/file-1/view',
web_content_link: nil }
logged = []
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
allow(drive_client).to receive(:each_material_file_under_folder) do |_folder_id, &block|
block.call(entry)
end
allow(MaterialSyncImporter).to receive(:import!)
.and_return(instance_double(MaterialSyncImporter::Result, action: :imported))
allow(Rails.logger).to receive(:info) { |message| logged << JSON.parse(message) }
described_class.new(source).sync!
progress_logs = logged.select { |row| row['folder_id'] == 'folder-123' }
expect(progress_logs.map { |row| row['progress'] }).to eq(['scan', 'summary'])
expect(progress_logs.last).to include(
'material_sync_source_id' => source.id,
'material_sync_source_name' => 'Drive source',
'scanned_count' => 1,
'imported' => 1,
'updated' => 0,
'unchanged' => 0,
'suppressed' => 0,
'failed' => 0)
end
end
end
+9
ファイルの表示
@@ -10,6 +10,15 @@ bundle exec rspec
bundle exec rails routes
```
Material video thumbnail generation requires `ffmpeg` on the host. If `ffmpeg`
is missing or frame extraction fails, `MaterialThumbnailGenerator` logs the
result and stderr, then leaves the existing thumbnail unchanged.
```sh
cd backend
bundle exec rails materials:thumbnails:backfill
```
## Frontend
```sh
+434 -2
ファイルの表示
@@ -118,23 +118,455 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting
## TypeScript and TSX formatting
- 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 compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- 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.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- 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.
- 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.
- 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
frontend 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.
- In this frontend, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
- Avoid reformatting unrelated JSX.
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit. Unless a subsection explicitly
mentions JSX, it applies equally to `.ts` and `.tsx`.
#### 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.
#### 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.
#### 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.
#### 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.
#### 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.
#### 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.
#### 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.
#### 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.
#### 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>)`.
#### 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.
#### 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.
#### Final TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line:
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.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
+144 -14
ファイルの表示
@@ -1,21 +1,38 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
Routes,
useLocation } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode,
applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots,
fetchUserSettings,
getClientThemeMode,
hasStoredClientThemeSelection,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
@@ -45,11 +62,51 @@ import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types'
const RouteTransitionWrapper = ({ user, setUser }: {
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation ()
if (animationMode === 'off')
{
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return (
<AnimatePresence mode="wait">
<Routes location={location}>
@@ -68,10 +125,11 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
@@ -99,6 +157,37 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)')
const handleThemeChange = () => {
if (getClientThemeMode () === 'system')
applyClientAppearance ()
}
mediaQuery.addEventListener ('change', handleThemeChange)
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
applyClientAnimationMode (animationMode)
}, [animationMode])
useEffect (() => {
const createUser = async () => {
@@ -133,6 +222,30 @@ const App: FC = () => {
createUser ()
}, [])
useEffect (() => {
if (!(user))
return
void (async () => {
try
{
const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (),
fetchUserThemeSlots (),
])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
setCachedUserThemeSlots (storedThemeSlots)
if (!(hasStoredClientThemeSelection ()))
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch
{
return
}
}) ()
}, [user])
switch (status)
{
case 503:
@@ -142,20 +255,37 @@ const App: FC = () => {
return (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
+46
ファイルの表示
@@ -0,0 +1,46 @@
import nikumaru from '@/assets/fonts/nikumaru.otf'
import type { FC } from 'react'
const ROW_COUNT = 8
const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
return (
<div
aria-hidden="true"
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
<style>{`
@font-face {
font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype');
}
`}</style>
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
<div
key={rowIndex}
className={
'flex min-h-32 items-center gap-12 '
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
}>
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
</span>))}
</div>))}
</div>
</div>)
}
export default DevModeWatermark
+75
ファイルの表示
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const dndKit = vi.hoisted (() => ({
useDraggable: vi.fn (),
useDroppable: vi.fn (),
}))
vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
)
}
const tagBody = (): HTMLElement => {
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
if (link == null)
throw new Error ('tag link not found')
const body = link.closest ('div')
if (body == null)
throw new Error ('tag body not found')
return body
}
describe ('DraggableDroppableTagRow', () => {
beforeEach (() => {
vi.clearAllMocks ()
dndKit.useDraggable.mockReturnValue ({
attributes: { 'aria-describedby': 'drag-source' },
listeners: { onPointerDown: vi.fn () },
setNodeRef: vi.fn (),
transform: null })
dndKit.useDroppable.mockReturnValue ({
isOver: false,
setNodeRef: vi.fn () })
})
it ('passes dndId through draggable data for active-row tracking', () => {
renderRow ()
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
id: 'tag-node:cat-general-7',
data: expect.objectContaining ({
dndId: 'tag-node:cat-general-7',
nestLevel: 2,
tagId: 7 }) }))
})
it ('hides only the active drag source from the explicit active dnd id', () => {
renderRow ('tag-node:cat-general-7')
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
})
it ('keeps inactive tag rows visible while another tag is dragged', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
})
+63 -21
ファイルの表示
@@ -4,6 +4,8 @@ import { motion } from 'framer-motion'
import { useRef } from 'react'
import TagLink from '@/components/TagLink'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react'
@@ -11,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
import type { Tag } from '@/types'
type Props = {
activeDndId?: string
tag: Tag
nestLevel: number
pathKey: string
@@ -19,7 +22,21 @@ type Props = {
sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const DraggableDroppableTagRow: FC<Props> = ({
activeDndId,
tag,
nestLevel,
pathKey,
parentTagId,
suppressClickRef,
sp,
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -45,18 +62,23 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
const { attributes,
listeners,
setNodeRef: setDragRef,
transform,
isDragging: dragging } = useDraggable ({ id: dndId,
data: { kind: 'tag',
tagId: tag.id,
parentTagId } })
transform } = useDraggable ({ id: dndId,
data: { kind: 'tag',
dndId,
tagId: tag.id,
parentTagId,
nestLevel } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId,
data: { kind: 'tag', tagId: tag.id } })
const activeDragging = activeDndId === dndId
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
visibility: dragging ? 'hidden' : 'visible' }
visibility: activeDragging ? 'hidden' : 'visible' }
const innerClassName = cn (
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
sp && 'touch-pan-y')
return (
<div
@@ -69,12 +91,16 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
return
const dx = e.clientX - p.x
const dy = e.clientY - p.y
if (dx * dx + dy * dy >= 9)
armEatNextClick ()
}}
onPointerUpCapture={() => {
downPosRef.current = null
}}
onPointerCancelCapture={() => {
downPosRef.current = null
}}
onClickCapture={e => {
if (suppressClickRef.current)
{
@@ -82,20 +108,36 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
e.stopPropagation ()
}
}}
ref={node => {
setDragRef (node)
setDropRef (node)
}}
style={style}
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
ref={setDropRef}
className={cn (
'min-w-0 max-w-full overflow-hidden rounded select-none',
sp && 'touch-pan-y',
over && 'ring-2 ring-offset-2')}
>
{activeDragging
? (
<div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</div>)
: (
<motion.div
ref={setDragRef}
style={style}
className={innerClassName}
{...attributes}
{...listeners}
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>)}
</div>)
}
export default DraggableDroppableTagRow
export default DraggableDroppableTagRow
+488 -75
ファイルの表示
@@ -1,23 +1,43 @@
import { Fragment, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'
import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { Tag } from '@/types'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => {
const parsed = Number.parseFloat (value)
return Number.isFinite (parsed) ? parsed : 0
}
const verticalChrome = (el: HTMLElement): number => {
const style = window.getComputedStyle (el)
return (
px (style.paddingTop)
+ px (style.paddingBottom)
+ px (style.borderTopWidth)
+ px (style.borderBottomWidth))
}
const setChildrenById = (
tags: TagWithDepth[],
tags: MaterialSidebarTag[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
children: MaterialSidebarTag[],
): MaterialSidebarTag[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }
@@ -25,75 +45,468 @@ const setChildrenById = (
if (tag.children.length === 0)
return tag
return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
}))
const MaterialSidebar: FC = () => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
const materialPath = (
tagId: number,
materialFilter: MaterialFilter,
): string =>
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ `&material_filter=${ materialFilter }`
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
id: tag.id,
name: tag.name,
category: tag.category,
deprecatedAt: tag.deprecated ? '' : null,
aliases: [],
parents: [],
postCount: 0,
createdAt: '',
updatedAt: '',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
matchedAlias: null })
return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
const tagSelectionShellClass = (selected: boolean): string =>
cn (selected
? ['rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700',
'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100']
: 'px-2 py-1')
const updateMaterialFilterQuery = (
pathname: string,
locationSearch: string,
navigate: ReturnType<typeof useNavigate>,
materialFilter: MaterialFilter,
) => {
const qs = new URLSearchParams (locationSearch)
qs.set ('material_filter', materialFilter)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
}
export default MaterialSidebar
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange },
) => (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold"></label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]}
</button>))}
</div>)
const MaterialTreeNode: FC<{
materialFilter: MaterialFilter
selectedTagId: number | null
nestLevel?: number
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => {
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
return (
<>
<li>
<div className="flex flex-center">
<div className="flex-none w-4 my-auto">
{tag.hasChildren && (
<button
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="text-neutral-500 dark:text-stone-400">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
<div className="min-w-0 flex-1 my-auto">
<div className={cn (tagSelectionShellClass (selectedTagId === tag.id),
'min-w-0 truncate')}>
<TagLink
tag={sidebarTagToTag (tag)}
nestLevel={nestLevel}
title={tag.name}
withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}/>
</div>
</div>
</div>
</li>
{open && tag.children.length > 0 && (
<ul>
{tag.children.map (child => (
<MaterialTreeNode
key={child.id}
tag={child}
nestLevel={nestLevel + 1}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>))}
</ul>)}
</>)
}
const MobileMaterialTreeNode: FC<{ depth?: number
availableInlineSizePx?: number | null
materialFilter: MaterialFilter
selectedTagId: number | null
onChildren: (tagId: number, children: MaterialSidebarTag[]) =>
void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag }> = (
{
depth = 0,
availableInlineSizePx = null,
materialFilter,
onChildren,
openTags,
selectedTagId,
setOpenTags,
tag,
},
) => {
const open = Boolean (openTags[tag.id])
const tagColumnRef = useRef<HTMLDivElement | null> (null)
const chipRef = useRef<HTMLDivElement | null> (null)
const buttonRef = useRef<HTMLButtonElement | null> (null)
const expansionSlotRef = useRef<HTMLDivElement | null> (null)
const expansionBorderRef = useRef<HTMLDivElement | null> (null)
const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState<number | null> (null)
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
useEffect (() => {
const tagColumn = tagColumnRef.current
const chip = chipRef.current
if (!(tagColumn) || !(chip))
return
const updateTagInlineSize = () => {
const buttonHeight = buttonRef.current?.offsetHeight ?? 0
const gap = tag.hasChildren ? px (window.getComputedStyle (tagColumn).rowGap) : 0
const columnHeight =
availableInlineSizePx == null
? tagColumn.clientHeight
: Math.min (tagColumn.clientHeight, availableInlineSizePx)
const nextChipInlineSize = Math.max (24, columnHeight - buttonHeight - gap)
const nextLinkInlineSize = Math.max (
16,
nextChipInlineSize - verticalChrome (chip),
)
setTagChipInlineSizePx (prev => prev === nextChipInlineSize ? prev : nextChipInlineSize)
setTagLinkInlineSizePx (prev => prev === nextLinkInlineSize ? prev : nextLinkInlineSize)
}
updateTagInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateTagInlineSize ()
})
resizeObserver.observe (tagColumn)
resizeObserver.observe (chip)
if (buttonRef.current)
resizeObserver.observe (buttonRef.current)
return () => {
resizeObserver.disconnect ()
}
}, [availableInlineSizePx, open, tag.hasChildren, tag.children.length])
useEffect (() => {
const expansionSlot = expansionSlotRef.current
const expansionBorder = expansionBorderRef.current
if (!(expansionSlot) || !(expansionBorder))
return
const updateChildInlineSize = () => {
const base =
availableInlineSizePx == null
? expansionSlot.clientHeight
: availableInlineSizePx
const chrome = verticalChrome (expansionSlot) + verticalChrome (expansionBorder)
const nextInlineSize = Math.max (24, base - chrome)
setChildAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
}
updateChildInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateChildInlineSize ()
})
resizeObserver.observe (expansionSlot)
resizeObserver.observe (expansionBorder)
return () => {
resizeObserver.disconnect ()
}
}, [availableInlineSizePx, open, tag.children.length])
return (
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
overflow-hidden">
<div
ref={tagColumnRef}
className="flex h-full min-h-0 max-h-full flex-col items-center gap-1
overflow-hidden">
<div
ref={chipRef}
className={cn (
tagSelectionShellClass (selectedTagId === tag.id),
'box-border rounded-xl border px-3 py-2 text-sm shadow-sm',
'min-h-0 overflow-hidden [max-inline-size:var(--tag-chip-inline-size)]',
'[max-height:var(--tag-chip-inline-size)]',
)}
style={{
writingMode: 'vertical-rl',
'--tag-chip-inline-size': (
tagChipInlineSizePx == null
? undefined
: `${ tagChipInlineSizePx }px`),
'--tag-link-inline-size': (
tagLinkInlineSizePx == null
? undefined
: `${ tagLinkInlineSizePx }px`),
} as CSSProperties}>
<TagLink
tag={sidebarTagToTag (tag)}
title={tag.name}
truncateOnMobile
withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}
className="block overflow-hidden text-ellipsis whitespace-nowrap
[max-inline-size:var(--tag-link-inline-size)]
[max-height:var(--tag-link-inline-size)]
[&_.tag-marquee]:block
[&_.tag-marquee]:h-full
[&_.tag-marquee]:overflow-hidden
[&_.tag-marquee__static]:h-full
[&_.tag-marquee__static]:overflow-hidden
[&_.tag-marquee__static]:text-ellipsis
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
</div>
{tag.hasChildren && (
<button
ref={buttonRef}
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="flex-none rounded-full border border-stone-300 bg-white
px-2 py-0.5 text-sm text-stone-700 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
{open && tag.children.length > 0 && (
<div
ref={expansionSlotRef}
className={cn (
'h-full min-h-0 max-h-full overflow-hidden box-border',
depth === 0 ? 'pt-5' : 'pt-3')}>
<div
ref={expansionBorderRef}
className="relative max-h-full overflow-hidden rounded-2xl border border-stone-200
bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100">
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
overflow-hidden">
<span
aria-hidden="true"
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
{tag.children.map (child => (
<div
key={child.id}
className="relative h-full min-h-0 max-h-full overflow-hidden">
<MobileMaterialTreeNode
tag={child}
depth={depth + 1}
availableInlineSizePx={childAvailableInlineSizePx}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>
</div>))}
</div>
</div>
</div>)}
</div>)
}
const MaterialSidebar: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const mobileRailRef = useRef<HTMLDivElement | null> (null)
const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] =
useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
useEffect (() => {
setDesktopTags (rootTags)
}, [rootTags])
useEffect (() => {
const el = mobileRailRef.current
if (!(el))
return
requestAnimationFrame (() => {
el.scrollLeft = el.scrollWidth
})
}, [rootTags, materialFilter])
useEffect (() => {
const el = mobileRailRef.current
if (!(el))
return
const updateAvailableInlineSize = () => {
const nextInlineSize = Math.max (24, el.clientHeight)
setMobileAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
}
updateAvailableInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateAvailableInlineSize ()
})
resizeObserver.observe (el)
return () => {
resizeObserver.disconnect ()
}
}, [rootTags, materialFilter])
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
setDesktopTags (prev => {
const base = prev.length > 0 ? prev : rootTags
return setChildrenById (base, tagId, children)
})
}
const handleFilterChange = (value: MaterialFilter) => {
setDesktopTags ([])
setOpenTags ({ })
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
}
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
tags.map (tag => (
<MaterialTreeNode
key={tag.id}
tag={tag}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>)))
return (
<>
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col
overflow-hidden">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
<div
ref={mobileRailRef}
className="mt-3 min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="flex min-w-max flex-row-reverse items-start gap-3 pb-1 h-full
min-h-0 max-h-full">
{visibleRootTags.map (tag => (
<MobileMaterialTreeNode
key={tag.id}
tag={tag}
availableInlineSizePx={mobileAvailableInlineSizePx}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>))}
</div>
</div>
</div>
<SidebarComponent sidebarKey="materials" className="hidden md:block">
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</>)
}
export default MaterialSidebar
+1 -2
ファイルの表示
@@ -107,8 +107,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
reason: 'niconico video length was not reported by embed'})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
+23
ファイルの表示
@@ -66,4 +66,27 @@ describe ('PostEditForm', () => {
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
})
it ('preserves duration while the video tag is temporarily removed', () => {
const post = buildPost ({
videoMs: 180_500,
tags: [
buildTag ({ id: 1, name: '動画', category: 'general' }),
buildTag ({ id: 2, name: 'general-tag', category: 'general' }),
],
})
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
})
+42 -11
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
@@ -9,29 +9,35 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils'
import { inputClass, msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types'
import type { Post, TagWithSections } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000)
const tagsToStr = (tags: Tag[]): string => {
const result: Tag[] = []
const tagsToStr = (tags: TagWithSections[]): string => {
const result: Omit<TagWithSections, 'children'>[] = []
const walk = (tag: Tag) => {
const walk = (tag: TagWithSections) => {
const { children, ...rest } = tag
result.push (rest)
children?.forEach (walk)
children.forEach (walk)
}
tags.filter (t => t.category !== 'nico').forEach (walk)
return [...(new Set (result.map (t => t.name)))].join (' ')
return [...(new Set (result.map (t =>
`${ t.name }${ t.sections
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
.join ('') }`)))].join (' ')
}
@@ -41,6 +47,7 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
@@ -52,6 +59,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
@@ -63,6 +74,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
videoMs: data.videoMs,
tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
@@ -102,6 +114,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
@@ -110,6 +123,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
@@ -124,6 +138,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
try
{
await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
@@ -134,7 +149,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
}
useEffect (() => {
setTags(tagsToStr (post.tags))
setTags (tagsToStr (post.tags))
setDuration (videoMsToDurationValue (post.videoMs))
}, [post])
return (
@@ -149,7 +165,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
{/* 親投稿 */}
@@ -181,6 +197,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */}
<Button type="submit" disabled={disabled}>
@@ -188,4 +218,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
</form>)
}
export default PostEditForm
+61 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, RefObject } from 'react'
@@ -24,16 +26,19 @@ type Props = {
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const PostEmbed: FC<Props> = (
{ ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError },
) => {
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +110,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
@@ -117,6 +126,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
const shouldLoadEmbed =
embedAutoLoadMode === 'auto'
|| (
embedAutoLoadMode === 'manual'
&& manualLoadRequested
)
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
</a>)
const manualLoadControl = (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
</Button>
{externalLink}
</div>)
switch (url.hostname.split ('.').slice (-2).join ('.'))
{
@@ -128,6 +159,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<NicoViewer
ref={ref}
@@ -150,6 +186,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId
const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
@@ -159,6 +200,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId))
break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
@@ -184,6 +230,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>)
: (
<div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => {
e.preventDefault ()
@@ -197,7 +248,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' }))
}}>
</a>
</a>)}
</div>)}
</>)
}
+6 -3
ファイルの表示
@@ -68,9 +68,12 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
return (
+35 -10
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,15 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
const cardLayoutTransition =
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -39,7 +49,8 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}>
<motion.div
ref={cardRef}
layoutId={layoutId}
layout={animationMode === 'off' ? false : true}
layoutId={animationMode === 'off' ? undefined : layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -54,22 +65,36 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return
return
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
transition={{ layout: cardLayoutTransition }}>
{thumbnailMode === 'off'
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
</div>)
: (
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async"
className="object-cover w-full h-full"/>)}
</motion.div>
</PrefetchLink>)
})}
</div>)
}
export default PostList
export default PostList
+1 -2
ファイルの表示
@@ -19,8 +19,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props,
) => (
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
+27 -4
ファイルの表示
@@ -5,6 +5,8 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -33,10 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
)
const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, location.origin)).toString ()
}, [to])
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -54,11 +66,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
@@ -77,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
@@ -99,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return (
<a ref={ref}
href={typeof to === 'string' ? to : createPath (to)}
href={path}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onClick={handleClick}
+254
ファイルの表示
@@ -0,0 +1,254 @@
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import type { FC } from 'react'
type Props = {
text: string
className?: string
truncateOnMobile?: boolean
title?: string }
const DESKTOP_MARQUEE_MEDIA =
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
const MARQUEE_START_DELAY_MS = 1600
const MARQUEE_END_HOLD_MS = 2400
const MARQUEE_SCROLL_PX_PER_SECOND = 43
const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null)
const animationRef = useRef<Animation | null> (null)
const timeoutRef = useRef<number | null> (null)
const rafRef = useRef<number | null> (null)
const [overflowPx, setOverflowPx] = useState (0)
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
const [active, setActive] = useState (false)
const [marqueeVisible, setMarqueeVisible] = useState (false)
useEffect (() => {
const outer = outerRef.current
const inner = staticRef.current
if (!(outer) || !(inner))
return
const measure = () => {
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
}
measure ()
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver (() => {
measure ()
})
resizeObserver?.observe (outer)
resizeObserver?.observe (inner)
addEventListener ('resize', measure)
return () => {
resizeObserver?.disconnect ()
removeEventListener ('resize', measure)
}
}, [text])
useEffect (() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
const update = () => {
setDesktopMarqueeEnabled (media.matches)
}
update ()
media.addEventListener ('change', update)
return () => {
media.removeEventListener ('change', update)
}
}, [])
useEffect (() => {
const clearScheduled = () => {
if (timeoutRef.current != null)
{
clearTimeout (timeoutRef.current)
timeoutRef.current = null
}
if (rafRef.current != null)
{
cancelAnimationFrame (rafRef.current)
rafRef.current = null
}
animationRef.current?.cancel ()
animationRef.current = null
}
const animated = animatedRef.current
const canMarquee = (
active
&& animationMode !== 'off'
&& desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null)
const resetAnimated = () => {
const node = animatedRef.current
animationRef.current?.cancel ()
animationRef.current = null
node?.getAnimations?.().forEach (animation => {
animation.cancel ()
})
if (!(node))
return
node.style.transition = 'none'
node.style.transform = 'translateX(0)'
}
if (!canMarquee)
{
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
return
}
let cancelled = false
const sleep = (ms: number) =>
new Promise<void> (resolve => {
timeoutRef.current = window.setTimeout (() => {
timeoutRef.current = null
resolve ()
}, ms)
})
const runLoop = async () => {
while (!cancelled)
{
resetAnimated ()
setMarqueeVisible (true)
await sleep (MARQUEE_START_DELAY_MS)
if (cancelled || !(animatedRef.current))
break
const moveDurationMs =
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
const node = animatedRef.current
if (typeof node.animate === 'function')
{
const animation = node.animate (
[
{ transform: 'translateX(0)' },
{ transform: `translateX(-${ overflowPx }px)` },
],
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
animationRef.current = animation
try
{
await animation.finished
}
catch
{
break
}
node.style.transform = `translateX(-${ overflowPx }px)`
animation.cancel ()
if (animationRef.current === animation)
animationRef.current = null
}
else
{
node.style.transition = `transform ${ moveDurationMs }ms linear`
await new Promise<void> (resolve => {
rafRef.current = requestAnimationFrame (() => {
rafRef.current = null
node.style.transform = `translateX(-${ overflowPx }px)`
resolve ()
})
})
await sleep (moveDurationMs)
node.style.transition = 'none'
node.style.transform = `translateX(-${ overflowPx }px)`
}
if (cancelled)
break
await sleep (MARQUEE_END_HOLD_MS)
if (cancelled)
break
resetAnimated ()
}
}
void runLoop ()
return () => {
cancelled = true
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
}
}, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
return (
<span
ref={outerRef}
title={title ?? text}
onMouseEnter={() => setActive (true)}
onMouseLeave={() => setActive (false)}
onFocus={() => setActive (true)}
onBlur={() => setActive (false)}
className={cn (
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:whitespace-nowrap',
className)}>
<span
ref={staticRef}
className={cn (
'tag-marquee__static block max-w-full',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
marqueeVisible && 'md:opacity-0')}>
{text}
</span>
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
<span
ref={animatedRef}
aria-hidden="true"
className={cn (
'tag-marquee__animated hidden md:block',
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
{text}
</span>)}
</span>)
}
export default ResponsiveMarqueeText
+182 -42
ファイルの表示
@@ -1,11 +1,13 @@
import { DndContext,
DragOverlay,
MeasuringStrategy,
MouseSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -20,30 +22,40 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, Tag } from '@/types'
import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: Tag[] }
type TagByCategory = { [key in Category]: TagWithSections[] }
const tagCollisionDetection: CollisionDetection = args => {
return pointerWithin (args)
}
const alwaysMeasureDroppables = {
droppable: { strategy: MeasuringStrategy.Always } } as const
const renderTagTree = (
tag: Tag,
activeDndId: string | undefined,
tag: TagWithSections,
nestLevel: number,
path: string,
suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number,
sp?: boolean,
): ReactNode[] => {
sp?: boolean): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const self = (
<li key={key} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={nestLevel}
pathKey={key}
@@ -57,13 +69,19 @@ const renderTagTree = (
...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child =>
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
renderTagTree (
activeDndId,
child,
nestLevel + 1,
key,
suppressClickRef,
tag.id,
sp)))
?? [])]
}
const isDescendant = (
root: Tag,
root: TagWithSections,
targetId: number,
): boolean => {
if (!(root.children))
@@ -84,8 +102,8 @@ const isDescendant = (
const findTag = (
byCat: TagByCategory,
id: number,
): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => {
): TagWithSections | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
for (const t of nodes)
{
if (t.id === id)
@@ -128,10 +146,40 @@ const buildTagByCategory = (post: Post): TagByCategory => {
}
const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
for (const category of CATEGORIES)
tagsTmp[category] = []
const visit = (tag: TagWithSections) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
tagsTmp[tag.category].push ({ ...tag, children: [] })
for (const child of tag.children ?? [])
visit (child)
}
for (const category of CATEGORIES)
for (const tag of byCategory[category] ?? [])
visit (tag)
for (const category of CATEGORIES)
tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}
const changeCategory = async (
tagId: number,
category: Category,
): Promise<void> => {
category: Category): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category })
}
@@ -147,14 +195,47 @@ const DropSlot = ({ cat }: { cat: Category }) => {
</li>)
}
const EmptyCategoryDropSection = (
{ cat, children }: { cat: Category
children: ReactNode }) => {
const { setNodeRef, isOver: over } = useDroppable ({
id: `slot:${ cat }`,
data: { kind: 'slot', cat } })
type Props = { post: Post; sp?: boolean }
return (
<div ref={setNodeRef} className="my-3">
{children}
<ul>
<li className="h-1">
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
</li>
</ul>
</div>)
}
const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
type Props = {
className?: string
post: Post
sp?: boolean }
type ActiveTagDrag = {
dndId: string
tagId: number
nestLevel: number }
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory
@@ -167,15 +248,20 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}, [post])
const [activeTagId, setActiveTagId] = useState<number | null> (null)
const [activeTagDrag, setActiveTagDrag] = useState<ActiveTagDrag | null> (null)
const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags)
const flatTagsByCategory = useMemo<TagByCategory> (
() => buildFlatTagByCategory (tags),
[tags],
)
const activeDndId = activeTagDrag?.dndId
const suppressClickRef = useRef (false)
@@ -280,13 +366,23 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}, [baseTags])
return (
<SidebarComponent>
<SidebarComponent sidebarKey="post-detail-tags" className={className}>
<TagSearch/>
<DndContext
sensors={sensors}
collisionDetection={tagCollisionDetection}
measuring={alwaysMeasureDroppables}
onDragStart={e => {
if (e.active.data.current?.kind === 'tag')
setActiveTagId (e.active.data.current?.tagId ?? null)
{
const tagId = e.active.data.current?.tagId
const nestLevel = e.active.data.current?.nestLevel
const dndId = e.active.data.current?.dndId
setActiveTagDrag (
tagId == null || nestLevel == null || dndId == null
? null
: { dndId, tagId, nestLevel })
}
setDragging (true)
suppressClickRef.current = true
document.body.style.userSelect = 'none'
@@ -294,42 +390,83 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
addEventListener ('click', e => {
e.preventDefault ()
e.stopPropagation ()
suppressClickRef.current = false
}, { capture: true, once: true })
suppressClickRef.current = false}, { capture: true, once: true })
}}
onDragCancel={() => {
setActiveTagId (null)
setActiveTagDrag (null)
setDragging (false)
document.body.style.userSelect = ''
suppressClickRef.current = false
}}
onDragEnd={async e => {
setActiveTagId (null)
setActiveTagDrag (null)
setDragging (false)
await onDragEnd (e)
document.body.style.userSelect = ''
}}
modifiers={[restrictToWindowEdges]}>
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}>
{CATEGORIES.map ((cat: Category) => {
const categoryTags =
tagRelationDisplay === 'grouped'
? (tags[cat] ?? [])
: (flatTagsByCategory[cat] ?? [])
if (!(categoryTags.length > 0 || dragging))
return null
const sectionTitle = (
<SubsectionTitle>
<motion.div
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
</SubsectionTitle>)
<ul>
{(tags[cat] ?? []).flatMap (tag => (
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
<DropSlot cat={cat}/>
</ul>
</div>))}
if (!(categoryTags.length > 0))
return (
<EmptyCategoryDropSection cat={cat} key={cat}>
{sectionTitle}
</EmptyCategoryDropSection>)
return (
<div className="my-3" key={cat}>
{sectionTitle}
<ul>
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
activeDndId,
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>)
})}
{post && (
<motion.div
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
layoutId={animationMode === 'off'
? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle></SectionTitle>
<ul>
<li>Id.: {post.id}</li>
@@ -366,11 +503,14 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</ul>
</motion.div>)}
<DragOverlay adjustScale={false}>
<DragOverlay
adjustScale={false}
modifiers={[snapCenterToCursor]}
dropAnimation={animationMode === 'off' ? null : undefined}>
<div className="pointer-events-none">
{activeTagId != null && (() => {
const tag = findTag (tags, activeTagId)
return tag && <TagLink tag={tag}/>
{activeTagDrag != null && (() => {
const tag = findTag (tags, activeTagDrag.tagId)
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
}) ()}
</div>
</DragOverlay>
@@ -378,4 +518,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</SidebarComponent>)
}
export default TagDetailSidebar
export default TagDetailSidebar
+62 -29
ファイルの表示
@@ -1,14 +1,15 @@
import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
type CommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
withWiki?: boolean
withCount?: boolean }
@@ -28,30 +29,42 @@ type Props =
const TagLink: FC<Props> = ({ tag,
nestLevel = 0,
linkFlg = true,
withWiki = true,
withCount = true,
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
const linkClass = cn (
spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
nestLevel = 0,
linkFlg = true,
truncateOnMobile = false,
withWiki = true,
withCount = true,
className,
style,
title,
...props }) => {
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
...style,
} as CSSProperties
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
return (
<>
<span className={rootClass}>
{(linkFlg && withWiki) && (
<span className="mr-1">
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
tag.materialId == null && !(tag.hasDeerjikists)
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
@@ -59,13 +72,15 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)))
: (
@@ -99,33 +114,51 @@ const TagLink: FC<Props> = ({ tag,
</span>)}
{nestLevel > 0 && (
<span
className="ml-1 mr-1"
className="ml-1 mr-1 shrink-0"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{tag.matchedAlias != null && (
<>
<span className={spanClass} {...props}>
{tag.matchedAlias}
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
<> </>
<span className="shrink-0"> </span>
</>)}
{linkFlg
? (
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
title={textTitle}
className={cn (linkClass, textClass, className)}
style={colourStyle}
{...props}>
{tag.name}
<ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</PrefetchLink>)
: (
<span className={spanClass}
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
{tag.name}
<ResponsiveMarqueeText
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{withCount && (
<span className="ml-1">{tag.postCount}</span>)}
</>)
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
export default TagLink
+1
ファイルの表示
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
value={search}
onChange={whenChanged}
+5 -3
ファイルの表示
@@ -20,12 +20,14 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
rounded shadow">
{suggestions.map ((tag, i) => (
<li key={tag.id}
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
onMouseDown={() => onSelect (tag)}>
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
</div>
</li>))}
</ul>)
}
export default TagSearchBox
export default TagSearchBox
+43 -20
ファイルの表示
@@ -8,6 +8,8 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, MouseEvent } from 'react'
@@ -21,6 +23,13 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const [tagsVsbl, setTagsVsbl] = useState (false)
const [tags, setTags] = useState<TagByCategory> ({ })
@@ -64,12 +73,19 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
{animationsOff
? (
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} onClick={onClick}/>
</div>)
: (
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>)}
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
@@ -95,25 +111,32 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</>)
return (
<SidebarComponent>
<SidebarComponent sidebarKey="posts-index-tags">
<TagSearch/>
<div className="hidden md:block mt-4">
{posts.length > 0 && TagBlock}
</div>
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>
{animationsOff
? (
tagsVsbl && (
<div className="md:hidden overflow-hidden">
{posts.length > 0 && TagBlock}
</div>))
: (
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={layoutTransition}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>)}
<a href="#"
className="md:hidden block my-2 text-center text-sm
@@ -128,4 +151,4 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</SidebarComponent>)
}
export default TagSidebar
export default TagSidebar
+297 -164
ファイルの表示
@@ -7,30 +7,37 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { Menu, MenuVisibleItem, Tag, User } from '@/types'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
export const menuOutline = ({ tag, wikiId, user, pathName }: {
tag?: Tag | null
wikiId: number | null
user: User | null,
pathName: string }): Menu => {
const postCount = tag?.postCount ?? 0
export const menuOutline = (
{ tag, material, wikiId, user, pathName }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null,
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
const materialFlg = /^\/materials\/\d+/.test (pathName)
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
@@ -49,12 +56,18 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: false, subMenu: [
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '抑止', to: '/materials/suppressions' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
{ name: `広場 (${ postCount || 0 })`,
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
visible: materialFlg && Boolean (material?.tag) },
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -79,6 +92,10 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
const TopNav: FC<Props> = ({ user }) => {
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
@@ -119,15 +136,23 @@ const TopNav: FC<Props> = ({ user }) => {
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
const effectiveTitle = (((tagFlg || materialFlg)
? location.pathname.split ('/')[2]
: wikiPage?.title)
?? '')
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
const { data: material } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: materialsKeys.show (effectiveTitle),
queryFn: () => fetchMaterial (effectiveTitle) })
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
@@ -135,6 +160,24 @@ const TopNav: FC<Props> = ({ user }) => {
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const topNavTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const opacityTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08 }
: { duration: .12 }
const highlightTransitionClass =
animationsOff
? undefined
: reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out'
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -169,13 +212,11 @@ const TopNav: FC<Props> = ({ user }) => {
return (
<>
<nav className="px-3 flex justify-between items-center w-full
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<nav className="top-nav-root px-3 flex justify-between items-center w-full">
<div className="flex items-center gap-2 h-12">
<PrefetchLink
to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
className="top-nav-brand-link mx-4 text-xl font-bold"
onClick={() => {
scroll (0, 0)
}}>
@@ -185,8 +226,8 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden
className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')}
'top-nav-highlight',
highlightTransitionClass)}
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
@@ -194,10 +235,12 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
layoutId={`menu-${ item.name }`}
{...(animationsOff
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: { duration: .12 },
layout: { duration: .2, ease: 'easeOut' } }}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
@@ -205,7 +248,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</PrefetchLink>
@@ -220,7 +263,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true)
measure (-1)
}}
className={cn ('relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo;
</PrefetchLink>
@@ -229,30 +272,28 @@ const TopNav: FC<Props> = ({ user }) => {
<TopNavUser user={user}/>
<a href="#"
className="md:hidden ml-auto pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={ev => {
ev.preventDefault ()
<button
type="button"
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</a>
</button>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
layout
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
{...(animationsOff ? { }
: { layout: true })}
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: topNavTransition }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
}}>
@@ -262,12 +303,14 @@ const TopNav: FC<Props> = ({ user }) => {
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
transition={{ duration: .2, ease: 'easeOut' }}
{...((item.visible ?? true)
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
@@ -278,30 +321,34 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="h-full flex items-center px-3">
className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name}
</PrefetchLink>
</motion.div>)))}
@@ -310,129 +357,215 @@ const TopNav: FC<Props> = ({ user }) => {
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>
{animationsOff
? (
<div className="absolute inset-0 flex items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`}>
{item.component}
</div>)
: (
<div key={`l-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={topNavTransition}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</motion.div>
</AnimatePresence>
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
{animationsOff
? (
menuOpen && (
<div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
{i === openItemIdx && (
<div className="top-nav-submenu w-full">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</div>)}
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</div>))
: (
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="top-nav-submenu w-full"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>)}
</>)
}
+5 -3
ファイルの表示
@@ -1,12 +1,14 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = { children: React.ReactNode }
type Props = { children: React.ReactNode; className?: string }
const PageTitle: FC<Props> = ({ children }) => (
<h1 className="text-2xl font-bold mb-2">
const PageTitle: FC<Props> = ({ children, className, ...rest }) => (
<h1 className={cn ('text-2xl font-bold mb-2', className)} {...rest}>
{children}
</h1>)
+1 -2
ファイルの表示
@@ -16,8 +16,7 @@ const range = (start: number, end: number): number[] =>
const getPages = (
page: number,
total: number,
siblingCount: number,
): (number | '…')[] => {
siblingCount: number): (number | '…')[] => {
if (total <= 1)
return [1]
+12
ファイルの表示
@@ -20,4 +20,16 @@ describe ('TabGroup', () => {
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
})
it ('renders tabs as buttons and shows the right addon', () => {
render (
<TabGroup rightAddon={<span> 20</span>}>
<Tab name="広場">Plaza</Tab>
</TabGroup>,
)
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
})
})
+26 -13
ファイルの表示
@@ -7,12 +7,15 @@ type TabProps = { name: string
init?: boolean
children: React.ReactNode }
type Props = { children: React.ReactNode }
type Props = {
children: React.ReactNode
rightAddon?: React.ReactNode
}
export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children }) => {
const TabGroup: FC<Props> = ({ children, rightAddon }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => {
@@ -22,17 +25,27 @@ const TabGroup: FC<Props> = ({ children }) => {
return (
<div className="mt-4">
<div className="flex gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
setCurrent (i)
}}>
{tab.props.name}
</a>))}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 gap-4">
{tabs.map ((tab, i) => (
<button
key={i}
type="button"
className={cn (
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === current && 'font-bold',
)}
onClick={() => {
setCurrent (i)
}}>
{tab.props.name}
</button>))}
</div>
{rightAddon && (
<div className="shrink-0">
{rightAddon}
</div>)}
</div>
<div className="mt-2">
{tabs[current]}
+17 -1
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
{ params: { q: '虹夏', nico: '0' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
it ('sends nico=1 only when includeNico is true', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="" setValue={setValue} includeNico={true}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
)
})
})
})

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