コミットを比較

..

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 37c91054a9 #352 2026-06-30 00:26:11 +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
58個のファイルの変更3806行の追加608行の削除
+6 -1
ファイルの表示
@@ -271,7 +271,12 @@ const value =
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab - In TypeScript and TSX, convert every leading run of 8 spaces to a tab
character. character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- Never place a closing parenthesis at the beginning of a line. - In TypeScript and TSX function declarations, including `const` arrow
function declarations, when the parameter list spans multiple lines, always
put the closing parenthesis at the beginning of its own line before the return
type or `=>`.
- 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. - Never place a closing square bracket at the beginning of a line.
- For object literals and other associative-array-style braces, do not place - For object literals and other associative-array-style braces, do not place
the closing brace at the beginning of a line. Function, lambda, callback, and the closing brace at the beginning of a line. Function, lambda, callback, and
+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
+177 -68
ファイルの表示
@@ -1,3 +1,6 @@
require 'set'
class MaterialsController < ApplicationController class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty
rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path
@@ -13,14 +16,14 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
filters = material_index_filters filters = material_index_filters
tag_graph = material_index_tag_graph(filters)
q = Material.includes(:material_export_items, q = Material.includes(:material_export_items,
thumbnail_attachment: :blob, thumbnail_attachment: :blob,
file_attachment: :blob, file_attachment: :blob,
tag: :tag_name) tag: :tag_name)
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' 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_from]) if filters[:created_from]
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to] 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_from]) if filters[:updated_from]
@@ -39,8 +42,16 @@ class MaterialsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
render json: { materials: MaterialRepr.list_many(materials, host: request.base_url), response = { materials: MaterialRepr.list_many(materials, host: request.base_url),
count: } count: }
if filters[:tag_id]
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
end
if filters[:group_by] == 'parent_tag'
response[:groups] = material_index_groups(materials, tag_graph)
end
render json: response
end end
def show def show
@@ -97,8 +108,7 @@ class MaterialsController < ApplicationController
end end
if material if material
MaterialThumbnailGenerator.generate!(material) log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
material.reload
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render_validation_error material
@@ -136,7 +146,6 @@ class MaterialsController < ApplicationController
material.assign_attributes(tag:, url:, updated_by_user: current_user) material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob if uploaded_blob
material.file.attach(uploaded_blob) material.file.attach(uploaded_blob)
clear_file_suppression!(material)
elsif params.key?(:url) && url.present? && file.blank? elsif params.key?(:url) && url.present? && file.blank?
material.file.detach material.file.detach
end end
@@ -150,8 +159,7 @@ class MaterialsController < ApplicationController
raise raise
end end
MaterialThumbnailGenerator.generate!(material) log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
material.reload
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
end end
@@ -182,37 +190,6 @@ class MaterialsController < ApplicationController
filename: "btrc-materials-#{ profile }.zip" filename: "btrc-materials-#{ profile }.zip"
end end
def suppress_file
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material
reason = params[:reason].to_s.strip.presence
return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason
purge = bool?(:purge)
file_snapshot = purge_material_file_snapshot(material) if purge
attachment = purge && material.file.attached? ? material.file.attachment : nil
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
material.update!(file_suppressed_at: Time.current,
file_suppressed_by_user: current_user,
file_suppression_reason: reason,
updated_by_user: current_user)
MaterialVersionRecorder.record!(material:, event_type: :suppress,
created_by_user: current_user,
file_snapshot:)
end
# Enqueue failure raises here after the suppress metadata has been committed.
# In that case the file remains suppressed in UI/ZIP and purge can be retried.
attachment&.purge_later
material.reload if purge
render json: MaterialRepr.base(material, host: request.base_url)
end
private private
def material_index_filters def material_index_filters
@@ -225,9 +202,6 @@ class MaterialsController < ApplicationController
media_kind = 'all' media_kind = 'all'
end end
suppression = params[:suppression].to_s.presence
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
sort = params[:sort].to_s.presence sort = params[:sort].to_s.presence
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size', unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'].include?(sort) 'version_no', 'id'].include?(sort)
@@ -237,10 +211,18 @@ class MaterialsController < ApplicationController
direction = params[:direction].to_s.downcase direction = params[:direction].to_s.downcase
direction = 'desc' unless ['asc', 'desc'].include?(direction) 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, { q: params[:q].to_s.strip.presence,
tag_state:, tag_state:,
media_kind:, media_kind:,
suppression:, tag_id:,
include_descendants: bool?(:include_descendants),
group_by:,
created_from: parse_time_param(:created_from), created_from: parse_time_param(:created_from),
created_to: parse_time_param(:created_to), created_to: parse_time_param(:created_to),
updated_from: parse_time_param(:updated_from), updated_from: parse_time_param(:updated_from),
@@ -310,6 +292,147 @@ class MaterialsController < ApplicationController
end end
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 def material_index_order_sql filters
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC' direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
sort_sql = sort_sql =
@@ -400,37 +523,23 @@ class MaterialsController < ApplicationController
content_type: file.content_type, content_type: file.content_type,
) )
if file_sha256.present? MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
blob.metadata['sha256'] = file_sha256
blob.save! if blob.changed?
end
blob blob
ensure ensure
file.tempfile.rewind if file&.tempfile file.tempfile.rewind if file&.tempfile
end end
def clear_file_suppression! material
material.file_suppressed_at = nil
material.file_suppressed_by_user = nil
material.file_suppression_reason = nil
end
def purge_material_file_snapshot material
return nil unless material.file.attached?
blob = material.file.blob
{ file_blob_id: blob.id,
file_filename: blob.filename.to_s,
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] ||
MaterialFileSha256.from_blob(blob, allow_download: true) }
end
def render_material_import_block block def render_material_import_block block
render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] }
end end
def log_thumbnail_generation material, result
material.reload
Rails.logger.info(
"Material thumbnail generation: material_id=#{ material.id } " \
"result=#{ result } " \
"thumbnail_attached=#{ material.thumbnail.attached? } " \
"content_type=#{ material.content_type }")
end
end end
+4 -3
ファイルの表示
@@ -140,10 +140,11 @@ class PostsController < ApplicationController
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids parent_post_ids = parse_parent_post_ids
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) if thumbnail.present? post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
@@ -156,8 +157,6 @@ class PostsController < ApplicationController
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end
@@ -167,6 +166,8 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
+10 -2
ファイルの表示
@@ -379,12 +379,20 @@ class TagsController < ApplicationController
end end
def visible_root_tag_ids graph def visible_root_tag_ids graph
graph[:tags_by_id].filter_map do |tag_id, _attrs| root_ids = Set.new
graph[:tags_by_id].each do |tag_id, attrs|
next unless visible_root_tag?(tag_id, graph) next unless visible_root_tag?(tag_id, graph)
next unless visible_subtree?(tag_id, graph) next unless visible_subtree?(tag_id, graph)
tag_id if attrs[:deprecated]
collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new)
else
root_ids << tag_id
end
end end
root_ids.to_a
end end
def visible_root_tag? tag_id, graph def visible_root_tag? tag_id, graph
+22 -5
ファイルの表示
@@ -1,6 +1,9 @@
class Material < ApplicationRecord class Material < ApplicationRecord
include MyDiscard include MyDiscard
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
'legacy_drive_path'].freeze
default_scope -> { kept } default_scope -> { kept }
belongs_to :parent, class_name: 'Material', optional: true belongs_to :parent, class_name: 'Material', optional: true
@@ -9,7 +12,6 @@ class Material < ApplicationRecord
belongs_to :tag, optional: true belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
belongs_to :file_suppressed_by_user, class_name: 'User', optional: true
has_many :material_versions, dependent: :destroy has_many :material_versions, dependent: :destroy
has_many :material_export_items, dependent: :destroy has_many :material_export_items, dependent: :destroy
@@ -17,13 +19,17 @@ class Material < ApplicationRecord
has_one_attached :file, dependent: :purge has_one_attached :file, dependent: :purge
has_one_attached :thumbnail, dependent: :purge has_one_attached :thumbnail, dependent: :purge
validates :tag_id, presence: true, uniqueness: true before_validation :assign_normalized_source_key
validates :tag_id, uniqueness: true, allow_nil: true
validates :source_kind,
inclusion: { in: SOURCE_KINDS },
allow_blank: true
validate :file_must_be_attached validate :file_must_be_attached
validate :tag_must_be_material_category validate :tag_must_be_material_category
def content_type def content_type
return nil if file_suppressed?
return nil unless file&.attached? return nil unless file&.attached?
file.blob.content_type file.blob.content_type
@@ -41,14 +47,25 @@ class Material < ApplicationRecord
file.blob.filename.to_s file.blob.filename.to_s
end end
def file_suppressed? = file_suppressed_at.present?
def snapshot_export_paths def snapshot_export_paths
material_export_items.order(:profile).pluck(:profile, :export_path).to_h material_export_items.order(:profile).pluck(:profile, :export_path).to_h
end end
private private
def assign_normalized_source_key
if source_kind.blank?
self.normalized_source_key = nil
return
end
self.normalized_source_key =
MaterialSyncSuppression.normalize_source_key(source_kind:,
source_uri:,
drive_path: source_path,
drive_file_id: source_file_id)
end
def file_must_be_attached def file_must_be_attached
return if url.present? || file.attached? return if url.present? || file.attached?
+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
+1 -2
ファイルの表示
@@ -2,8 +2,7 @@ class MaterialVersion < ApplicationRecord
EVENT_TYPE_MAP = { create: 'create', EVENT_TYPE_MAP = { create: 'create',
update: 'update', update: 'update',
discard: 'discard', discard: 'discard',
restore: 'restore', restore: 'restore' }.freeze
suppress: 'suppress' }.freeze
include VersionRecord include VersionRecord
+15 -6
ファイルの表示
@@ -1,5 +1,19 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
require 'stringio'
def self.resized_thumbnail_attachment(upload)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image.resize '180x180'
image.format 'jpg'
{ io: StringIO.new(image.to_blob),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
ensure
upload.rewind
end
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
@@ -87,12 +101,7 @@ class Post < ApplicationRecord
def resized_thumbnail! def resized_thumbnail!
return unless thumbnail.attached? return unless thumbnail.attached?
image = MiniMagick::Image.read(thumbnail.download) thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
image.resize '180x180'
thumbnail.purge
thumbnail.attach(io: File.open(image.path),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
end end
private private
+9 -6
ファイルの表示
@@ -2,8 +2,9 @@
module MaterialRepr module MaterialRepr
BASE = { only: [:id, :url, :version_no, :file_suppressed_at, BASE = { only: [:id, :url, :version_no, :source_kind, :source_uri,
:file_suppression_reason, :created_at, :updated_at], :source_path, :source_file_id, :normalized_source_key,
:created_at, :updated_at],
methods: [:content_type], methods: [:content_type],
include: { tag: TagRepr::BASE, include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE, created_by_user: UserRepr::BASE,
@@ -13,7 +14,7 @@ module MaterialRepr
def base material, host: def base material, host:
material.as_json(BASE).merge( material.as_json(BASE).merge(
file: if material.file.attached? && !material.file_suppressed? file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end, end,
@@ -34,6 +35,11 @@ module MaterialRepr
{ id: material.id, { id: material.id,
version_no: material.version_no, version_no: material.version_no,
url: material.url, 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), tag: compact_tag(material.tag),
thumbnail: thumbnail_url(material, host:), thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material), thumbnail_fallback_text: thumbnail_fallback_text(material),
@@ -41,7 +47,6 @@ module MaterialRepr
media_kind: media_kind(material), media_kind: media_kind(material),
content_type: material.content_type, content_type: material.content_type,
file_byte_size: material.file_byte_size, file_byte_size: material.file_byte_size,
file_suppressed_at: material.file_suppressed_at,
created_at: material.created_at, created_at: material.created_at,
updated_at: material.updated_at, updated_at: material.updated_at,
export_paths: export_paths(material), export_paths: export_paths(material),
@@ -68,7 +73,6 @@ module MaterialRepr
end end
def thumbnail_url material, host: def thumbnail_url material, host:
return nil if material.file_suppressed?
return nil unless material.thumbnail.attached? return nil unless material.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
@@ -84,7 +88,6 @@ module MaterialRepr
end end
def media_kind material def media_kind material
return 'suppressed' if material.file_suppressed?
return 'url_only' unless material.file.attached? return 'url_only' unless material.file.attached?
content_type = material.file.blob.content_type.to_s content_type = material.file.blob.content_type.to_s
+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
+26 -3
ファイルの表示
@@ -1,16 +1,39 @@
require 'digest' require 'digest'
require 'json'
class MaterialFileSha256 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 def self.from_blob blob, allow_download: false
sha256 = blob.metadata['sha256'] sha256 = metadata_sha256(blob)
return sha256 if sha256.present? return sha256 if sha256.present?
return nil unless allow_download return nil unless allow_download
begin begin
blob.open do |file| blob.open do |file|
sha256 = Digest::SHA256.file(file.path).hexdigest sha256 = Digest::SHA256.file(file.path).hexdigest
blob.metadata['sha256'] = sha256 assign_metadata_sha256!(blob, sha256)
blob.save! if blob.changed?
sha256 sha256
end end
rescue ActiveStorage::FileNotFoundError, ArgumentError => error rescue ActiveStorage::FileNotFoundError, ArgumentError => error
+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
+53 -13
ファイルの表示
@@ -11,25 +11,30 @@ class MaterialThumbnailGenerator
class << self class << self
def generate! material def generate! material
new(material).generate! new(material).generate!
rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e
Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }")
nil
end end
end end
def initialize material def initialize material
@material = material @material = material
@ffmpeg_stderr = []
end end
def generate! def generate!
@material.thumbnail.purge if @material.thumbnail.attached? return log_result(:no_file) unless @material.file.attached?
return unless @material.file.attached? return log_result(:unsupported_content_type) unless image? || video?
return unless image? || video?
@material.file.blob.open do |file| @material.file.blob.open do |file|
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path) thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
attach_thumbnail(thumbnail) if thumbnail return log_result(:generation_failed) unless thumbnail
return attach_thumbnail(thumbnail)
end 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 end
private private
@@ -38,7 +43,11 @@ class MaterialThumbnailGenerator
def video? = content_type.start_with?('video/') def video? = content_type.start_with?('video/')
def content_type = @material.file.blob.content_type.to_s def content_type
return nil unless @material.file.attached?
@material.file.blob.content_type.to_s
end
def image_thumbnail path def image_thumbnail path
image = MiniMagick::Image.open(path) image = MiniMagick::Image.open(path)
@@ -71,16 +80,47 @@ class MaterialThumbnailGenerator
'-frames:v', '1', '-frames:v', '1',
'-f', 'image2', '-f', 'image2',
output_path) output_path)
Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success? @ffmpeg_stderr << stderr if stderr.present?
status.success? status.success?
rescue Errno::ENOENT => e rescue Errno::ENOENT => e
Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }") @ffmpeg_stderr << "ffmpeg unavailable: #{ e.message }"
false false
end end
def attach_thumbnail image def attach_thumbnail image
@material.thumbnail.attach(io: File.open(image.path), blob = nil
filename: 'material-thumbnail.jpg', File.open(image.path) do |io|
content_type: 'image/jpeg') 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
end end
+8 -5
ファイルの表示
@@ -1,5 +1,5 @@
class MaterialVersionRecorder < VersionRecorder class MaterialVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
new(material:, event_type:, created_by_user:, file_snapshot:).record! new(material:, event_type:, created_by_user:, file_snapshot:).record!
@@ -33,6 +33,11 @@ class MaterialVersionRecorder < VersionRecorder
tag: @record.tag, tag: @record.tag,
tag_name: @record.tag&.name, tag_name: @record.tag&.name,
tag_category: @record.tag&.category, 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, export_paths_json: @record.snapshot_export_paths,
discarded_at: @record.discarded_at, discarded_at: @record.discarded_at,
file_blob_id: file_snapshot[:file_blob_id], file_blob_id: file_snapshot[:file_blob_id],
@@ -40,9 +45,7 @@ class MaterialVersionRecorder < VersionRecorder
file_content_type: file_snapshot[:file_content_type], file_content_type: file_snapshot[:file_content_type],
file_byte_size: file_snapshot[:file_byte_size], file_byte_size: file_snapshot[:file_byte_size],
file_checksum: file_snapshot[:file_checksum], file_checksum: file_snapshot[:file_checksum],
file_sha256: file_snapshot[:file_sha256], file_sha256: file_snapshot[:file_sha256] }
file_suppressed_at: @record.file_suppressed_at,
file_suppression_reason: @record.file_suppression_reason }
end end
def build_file_snapshot blob def build_file_snapshot blob
@@ -54,7 +57,7 @@ class MaterialVersionRecorder < VersionRecorder
file_content_type: blob.content_type, file_content_type: blob.content_type,
file_byte_size: blob.byte_size, file_byte_size: blob.byte_size,
file_checksum: blob.checksum, file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] } file_sha256: MaterialFileSha256.metadata_sha256(blob) }
end end
def empty_file_snapshot def empty_file_snapshot
-1
ファイルの表示
@@ -40,7 +40,6 @@ class MaterialZipExporter
.joins(:material) .joins(:material)
.merge(Material.kept) .merge(Material.kept)
.where(profile: @profile) .where(profile: @profile)
.where(materials: { file_suppressed_at: nil })
.order(:export_path) .order(:export_path)
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
+3 -5
ファイルの表示
@@ -114,9 +114,7 @@ Rails.application.routes.draw do
end end
get 'materials/download.zip', to: 'materials#download' get 'materials/download.zip', to: 'materials#download'
resources :materials, only: [:index, :show, :create, :update, :destroy] do get 'materials/versions', to: 'material_versions#index'
member do resources :material_sync_suppressions, path: 'materials/suppressions', only: [:index, :create]
patch :suppress_file resources :materials, only: [:index, :show, :create, :update, :destroy]
end
end
end end
+1 -11
ファイルの表示
@@ -2,9 +2,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
def up def up
change_table :materials, bulk: true do |t| change_table :materials, bulk: true do |t|
t.integer :version_no, null: false, default: 1 t.integer :version_no, null: false, default: 1
t.datetime :file_suppressed_at
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
t.string :file_suppression_reason
end end
change_table :material_versions, bulk: true do |t| change_table :material_versions, bulk: true do |t|
@@ -18,8 +15,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
t.bigint :file_byte_size t.bigint :file_byte_size
t.string :file_checksum t.string :file_checksum
t.string :file_sha256 t.string :file_sha256
t.datetime :file_suppressed_at
t.string :file_suppression_reason
end end
execute <<~SQL.squish execute <<~SQL.squish
@@ -35,7 +30,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
add_index :material_versions, :file_blob_id add_index :material_versions, :file_blob_id
add_check_constraint :material_versions, add_check_constraint :material_versions,
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')", "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'material_versions_event_type_valid' name: 'material_versions_event_type_valid'
create_table :material_export_items do |t| create_table :material_export_items do |t|
@@ -87,12 +82,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
remove_column :material_versions, :file_byte_size remove_column :material_versions, :file_byte_size
remove_column :material_versions, :file_checksum remove_column :material_versions, :file_checksum
remove_column :material_versions, :file_sha256 remove_column :material_versions, :file_sha256
remove_column :material_versions, :file_suppressed_at
remove_column :material_versions, :file_suppression_reason
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
remove_column :materials, :version_no remove_column :materials, :version_no
remove_column :materials, :file_suppressed_at
remove_column :materials, :file_suppression_reason
end end
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
生成ファイル
+54 -9
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -156,6 +156,43 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id"
end 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| create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false t.bigint "material_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
@@ -177,8 +214,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
t.bigint "file_byte_size" t.bigint "file_byte_size"
t.string "file_checksum" t.string "file_checksum"
t.string "file_sha256" t.string "file_sha256"
t.datetime "file_suppressed_at" t.string "source_kind"
t.string "file_suppression_reason" 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 ["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 ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id" t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
@@ -188,7 +228,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
t.index ["tag_id"], name: "index_material_versions_on_tag_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 ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url" t.index ["url"], name: "index_material_versions_on_url"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "material_versions_event_type_valid"
end end
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -202,13 +242,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
t.datetime "discarded_at" t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)" t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.integer "version_no", default: 1, null: false t.integer "version_no", default: 1, null: false
t.datetime "file_suppressed_at" t.string "source_kind"
t.bigint "file_suppressed_by_user_id" t.string "source_uri"
t.string "file_suppression_reason" 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 ["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 ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at" t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id"
t.index ["parent_id"], name: "index_materials_on_parent_id" t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id" t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id" t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
@@ -615,6 +658,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id" 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_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"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags" add_foreign_key "material_versions", "tags"
@@ -623,7 +669,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do
add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "file_suppressed_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
+18
ファイルの表示
@@ -7,3 +7,21 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name) # MovieGenre.find_or_create_by!(name: genre_name)
# end # 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
+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
+138 -52
ファイルの表示
@@ -64,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do
'name' => 'material_index_b', 'name' => 'material_index_b',
'category' => 'material' '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['content_type']).to eq('image/png')
expect(row['media_kind']).to eq('image')
end end
it 'filters materials by tag_id' do it 'filters materials by q' do
get '/materials', params: { tag_id: material_a.tag_id } get '/materials', params: { q: 'material_index_a' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1) expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id]) expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end end
it 'filters materials by parent_id' do it 'filters materials by tag_state' do
get '/materials', params: { parent_id: material_a.id } 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(response).to have_http_status(:ok)
expect(json['count']).to eq(1) 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 end
it 'paginates and keeps total count' do it 'paginates and keeps total count' do
@@ -104,6 +113,68 @@ RSpec.describe 'Materials API', type: :request do
expect(response_materials.size).to eq(1) expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id) expect(response_materials.first['id']).to eq(material_b.id)
end 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 end
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
@@ -528,9 +599,8 @@ RSpec.describe 'Materials API', type: :request do
expect(response.body.b).not_to include('素材/b.png'.b) expect(response.body.b).not_to include('素材/b.png'.b)
end end
it 'does not include suppressed materials' do it 'does not include disabled export items' do
material_b.update!(file_suppressed_at: Time.current, material_b.material_export_items.first.update!(enabled: false)
file_suppression_reason: 'copyright_high_risk')
get '/materials/download.zip', params: { profile: 'legacy_drive' } get '/materials/download.zip', params: { profile: 'legacy_drive' }
@@ -540,62 +610,78 @@ RSpec.describe 'Materials API', type: :request do
end end
end end
describe 'PATCH /materials/:id/suppress_file' do describe 'GET /materials/versions' do
let!(:tag) do let!(:tag) do
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
end end
it 'allows admin to suppress a file and records a suppress version' do it 'returns material versions in reverse chronological order' do
sign_in_as(admin_user) 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, MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user) created_by_user: member_user)
expect do get '/materials/versions', params: {
patch "/materials/#{ material.id }/suppress_file", tag: 'material_history',
params: { reason: 'copyright_high_risk' } event_type: 'create'
end.to change(MaterialVersion, :count).by(1) }
expect(response).to have_http_status(:ok) 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
material.reload describe 'material sync suppressions' do
expect(material.file_suppressed_at).to be_present it 'requires member permission for index' do
expect(material.file_suppressed_by_user).to eq(admin_user) sign_out
expect(material.file_suppression_reason).to eq('copyright_high_risk')
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress') get '/materials/suppressions'
expect(json['file']).to be_nil
expect(json['file_suppressed_at']).to be_present expect(response).to have_http_status(:unauthorized)
end end
it 'purges blob when purge=true is requested' do it 'creates source suppression as member' do
sign_in_as(admin_user)
old_blob_id = material.file.blob.id
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
expect do
patch "/materials/#{ material.id }/suppress_file",
params: { reason: 'copyright_takedown', purge: '1' }
end.to have_enqueued_job(ActiveStorage::PurgeJob)
expect(response).to have_http_status(:ok)
version = material.material_versions.order(:version_no).last
expect(version.event_type).to eq('suppress')
expect(version.file_blob_id).to eq(old_blob_id)
expect(version.file_filename).to eq('suppress.png')
expect(version.file_sha256).to be_present
end
it 'rejects member suppression' do
sign_in_as(member_user) sign_in_as(member_user)
patch "/materials/#{ material.id }/suppress_file", expect do
params: { reason: 'copyright_high_risk' } 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(:forbidden) 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
end end
+78
ファイルの表示
@@ -1,4 +1,5 @@
require 'rails_helper' require 'rails_helper'
require 'base64'
require 'set' require 'set'
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
@@ -8,6 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true) 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 end
def create_nico_tag!(name) 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') Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end 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 = { } def post_write_params params = { }
{ parent_post_ids: '' }.merge(params) { parent_post_ids: '' }.merge(params)
end end
@@ -696,6 +714,66 @@ RSpec.describe 'Posts API', type: :request do
expect(json['tags'][0]).to have_key('name') expect(json['tags'][0]).to have_key('name')
end 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 it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member) sign_in_as(member)
+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 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 ## Frontend
```sh ```sh
+5 -2
ファイルの表示
@@ -14,8 +14,10 @@ import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' // import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage' import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage' import GekanatorPage from '@/pages/GekanatorPage'
@@ -68,10 +70,11 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage user={user}/>}/> <Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route> </Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/> <Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
+270 -78
ファイルの表示
@@ -1,28 +1,43 @@
import { Fragment, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { materialsKeys } from '@/lib/queryKeys' import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
const FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const FILTER_LABELS: Record<MaterialFilter, string> = {
present: '素材あり', const px = (value: string): number => {
missing: '素材なし', const parsed = Number.parseFloat (value)
any: 'すべて'} 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 = ( const setChildrenById = (
tags: MaterialSidebarTag[], tags: MaterialSidebarTag[],
targetId: number, targetId: number,
children: MaterialSidebarTag[]): MaterialSidebarTag[] => ( children: MaterialSidebarTag[],
): MaterialSidebarTag[] => (
tags.map (tag => { tags.map (tag => {
if (tag.id === targetId) if (tag.id === targetId)
return { ...tag, children } return { ...tag, children }
@@ -35,8 +50,11 @@ const setChildrenById = (
const materialPath = ( const materialPath = (
tagName: string, tagId: number,
materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` materialFilter: MaterialFilter,
): string =>
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ `&material_filter=${ materialFilter }`
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
@@ -55,33 +73,43 @@ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
matchedAlias: null }) matchedAlias: null })
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 = ( const updateMaterialFilterQuery = (
pathname: string, pathname: string,
locationSearch: string, locationSearch: string,
navigate: ReturnType<typeof useNavigate>, navigate: ReturnType<typeof useNavigate>,
materialFilter: MaterialFilter) => { materialFilter: MaterialFilter,
const qs = new URLSearchParams (locationSearch) ) => {
qs.set ('material_filter', materialFilter) const qs = new URLSearchParams (locationSearch)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`) qs.set ('material_filter', materialFilter)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
} }
const MaterialFilterButtons: FC<{ const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
materialFilter: MaterialFilter onChange: (materialFilter: MaterialFilter) => void }> = (
onChange: (materialFilter: MaterialFilter) => void { materialFilter, onChange },
}> = ({ materialFilter, onChange }) => ( ) => (
<div className="flex flex-wrap gap-2"> <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 => ( {FILTERS.map (value => (
<button <button
key={value} key={value}
type="button" type="button"
onClick={() => onChange (value)} onClick={() => onChange (value)}
className={`rounded-full border px-3 py-1 text-sm ${ className={cn (
materialFilter === value 'rounded-full border px-3 py-1 text-sm',
? 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400 ' + (materialFilter === value
'dark:bg-sky-950 dark:text-sky-100' ? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
: 'border-neutral-300 bg-white text-neutral-700 dark:border-stone-700 ' + 'dark:bg-sky-950 dark:text-sky-100']
'dark:bg-stone-900 dark:text-stone-200' }`}> : ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]} {FILTER_LABELS[value]}
</button>))} </button>))}
</div>) </div>)
@@ -89,12 +117,14 @@ const MaterialFilterButtons: FC<{
const MaterialTreeNode: FC<{ const MaterialTreeNode: FC<{
materialFilter: MaterialFilter materialFilter: MaterialFilter
selectedTagId: number | null
nestLevel?: number nestLevel?: number
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
openTags: Record<number, boolean> openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>> setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => { }> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => {
const open = Boolean (openTags[tag.id]) const open = Boolean (openTags[tag.id])
const { data } = useQuery ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
@@ -107,10 +137,10 @@ const MaterialTreeNode: FC<{
}, [data, onChildren, open, tag.children.length, tag.id]) }, [data, onChildren, open, tag.children.length, tag.id])
return ( return (
<Fragment> <>
<li> <li>
<div className="flex"> <div className="flex flex-center">
<div className="flex-none w-4"> <div className="flex-none w-4 my-auto">
{tag.hasChildren && ( {tag.hasChildren && (
<button <button
type="button" type="button"
@@ -119,14 +149,17 @@ const MaterialTreeNode: FC<{
{open ? <>&minus;</> : '+'} {open ? <>&minus;</> : '+'}
</button>)} </button>)}
</div> </div>
<div className="flex-1 truncate"> <div className="min-w-0 flex-1 my-auto">
<TagLink <div className={cn (tagSelectionShellClass (selectedTagId === tag.id),
tag={sidebarTagToTag (tag)} 'min-w-0 truncate')}>
nestLevel={nestLevel} <TagLink
title={tag.name} tag={sidebarTagToTag (tag)}
withCount={false} nestLevel={nestLevel}
withWiki={false} title={tag.name}
to={materialPath (tag.name, materialFilter)}/> withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}/>
</div>
</div> </div>
</div> </div>
</li> </li>
@@ -138,23 +171,44 @@ const MaterialTreeNode: FC<{
tag={child} tag={child}
nestLevel={nestLevel + 1} nestLevel={nestLevel + 1}
materialFilter={materialFilter} materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags} openTags={openTags}
setOpenTags={setOpenTags} setOpenTags={setOpenTags}
onChildren={onChildren}/>))} onChildren={onChildren}/>))}
</ul>)} </ul>)}
</Fragment>) </>)
} }
const MobileMaterialTreeNode: FC<{ const MobileMaterialTreeNode: FC<{ depth?: number
depth?: number availableInlineSizePx?: number | null
materialFilter: MaterialFilter materialFilter: MaterialFilter
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void selectedTagId: number | null
openTags: Record<number, boolean> onChildren: (tagId: number, children: MaterialSidebarTag[]) =>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>> void
tag: MaterialSidebarTag openTags: Record<number, boolean>
}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => { 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 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 ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
@@ -165,50 +219,155 @@ const MobileMaterialTreeNode: FC<{
onChildren (tag.id, data) onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id]) }, [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 ( return (
<div className="flex flex-row-reverse items-start gap-2"> <div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
<div className="flex flex-col items-center gap-1"> overflow-hidden">
<div
ref={tagColumnRef}
className="flex h-full min-h-0 max-h-full flex-col items-center gap-1
overflow-hidden">
<div <div
className="rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm ref={chipRef}
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900 className={cn (
dark:text-stone-100" tagSelectionShellClass (selectedTagId === tag.id),
style={{ writingMode: 'vertical-rl' }}> '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 <TagLink
tag={sidebarTagToTag (tag)} tag={sidebarTagToTag (tag)}
title={tag.name} title={tag.name}
withCount={false} withCount={false}
withWiki={false} withWiki={false}
to={materialPath (tag.name, materialFilter)}/> 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)]"/>
</div> </div>
{tag.hasChildren && ( {tag.hasChildren && (
<button <button
ref={buttonRef}
type="button" type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))} onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="rounded-full border border-stone-300 bg-white px-2 py-0.5 className="flex-none rounded-full border border-stone-300 bg-white
text-sm text-stone-700 dark:border-stone-700 px-2 py-0.5 text-sm text-stone-700 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100"> dark:bg-stone-900 dark:text-stone-100">
{open ? <>&minus;</> : '+'} {open ? <>&minus;</> : '+'}
</button>)} </button>)}
</div> </div>
{open && tag.children.length > 0 && ( {open && tag.children.length > 0 && (
<div <div
className="relative flex flex-row-reverse items-start gap-2 rounded-2xl border ref={expansionSlotRef}
border-stone-200 bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900 className={cn (
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100" 'h-full min-h-0 max-h-full overflow-hidden box-border',
style={{ marginTop: `${ depth === 0 ? 1.25 : .75 }rem` }}> depth === 0 ? 'pt-5' : 'pt-3')}>
<span <div
aria-hidden="true" ref={expansionBorderRef}
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/> className="relative max-h-full overflow-hidden rounded-2xl border border-stone-200
{tag.children.map (child => ( bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
<div key={child.id} className="relative"> dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100">
<MobileMaterialTreeNode <div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
tag={child} overflow-hidden">
depth={depth + 1} <span
materialFilter={materialFilter} aria-hidden="true"
openTags={openTags} className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
setOpenTags={setOpenTags} {tag.children.map (child => (
onChildren={onChildren}/> <div
</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>)}
</div>) </div>)
} }
@@ -218,11 +377,14 @@ const MaterialSidebar: FC = () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const qs = new URLSearchParams (location.search) const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([]) const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const mobileRailRef = useRef<HTMLDivElement | null> (null) const mobileRailRef = useRef<HTMLDivElement | null> (null)
const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] =
useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({ const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
@@ -242,6 +404,29 @@ const MaterialSidebar: FC = () => {
}) })
}, [rootTags, materialFilter]) }, [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 visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => { const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
@@ -263,6 +448,7 @@ const MaterialSidebar: FC = () => {
key={tag.id} key={tag.id}
tag={tag} tag={tag}
materialFilter={materialFilter} materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags} openTags={openTags}
setOpenTags={setOpenTags} setOpenTags={setOpenTags}
onChildren={setChildren}/>))) onChildren={setChildren}/>)))
@@ -270,17 +456,23 @@ const MaterialSidebar: FC = () => {
return ( return (
<> <>
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950 <div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
dark:text-stone-100 md:hidden"> dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col
overflow-hidden">
<MaterialFilterButtons <MaterialFilterButtons
materialFilter={materialFilter} materialFilter={materialFilter}
onChange={handleFilterChange}/> onChange={handleFilterChange}/>
<div ref={mobileRailRef} className="mt-3 overflow-x-auto"> <div
<div className="flex min-w-max flex-row-reverse gap-3 pb-1"> 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 => ( {visibleRootTags.map (tag => (
<MobileMaterialTreeNode <MobileMaterialTreeNode
key={tag.id} key={tag.id}
tag={tag} tag={tag}
availableInlineSizePx={mobileAvailableInlineSizePx}
materialFilter={materialFilter} materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags} openTags={openTags}
setOpenTags={setOpenTags} setOpenTags={setOpenTags}
onChildren={setChildren}/>))} onChildren={setChildren}/>))}
+9 -8
ファイルの表示
@@ -28,11 +28,12 @@ type Props =
const TagLink: FC<Props> = ({ tag, const TagLink: FC<Props> = ({ tag,
nestLevel = 0, nestLevel = 0,
linkFlg = true, linkFlg = true,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
...props }) => { className,
...props }) => {
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -105,7 +106,7 @@ const TagLink: FC<Props> = ({ tag,
</span>)} </span>)}
{tag.matchedAlias != null && ( {tag.matchedAlias != null && (
<> <>
<span className={spanClass} {...props}> <span className={cn (spanClass, className)} {...props}>
{tag.matchedAlias} {tag.matchedAlias}
</span> </span>
<> </> <> </>
@@ -114,12 +115,12 @@ const TagLink: FC<Props> = ({ tag,
? ( ? (
<PrefetchLink <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass} className={cn (linkClass, className)}
{...props}> {...props}>
{tag.name} {tag.name}
</PrefetchLink>) </PrefetchLink>)
: ( : (
<span className={spanClass} <span className={cn (spanClass, className)}
{...props}> {...props}>
{tag.name} {tag.name}
</span>)} </span>)}
+34 -14
ファイルの表示
@@ -7,30 +7,36 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser' import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki' import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react' 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 } type Props = { user: User | null }
export const menuOutline = ({ tag, wikiId, user, pathName }: { export const menuOutline = (
tag?: Tag | null { tag, material, wikiId, user, pathName }: {
wikiId: number | null tag?: Tag | null
user: User | null, material?: Material | null
pathName: string }): Menu => { wikiId: number | null
const postCount = tag?.postCount ?? 0 user: User | null,
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? '' const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName) const tagFlg = /^\/tags\/\d+/.test (pathName)
const materialFlg = /^\/materials\/\d+/.test (pathName)
return [ return [
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
@@ -49,12 +55,18 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: tagFlg }, visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`, { name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] }, visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: false, subMenu: [ { name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' }, { name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false }, { name: '抑止', to: '/materials/suppressions' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { 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: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' }, { name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' }, { name: '新規', to: '/wiki/new' },
@@ -119,15 +131,23 @@ const TopNav: FC<Props> = ({ user }) => {
queryFn: () => fetchWikiPage (wikiIdStr, { }) }) queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname) 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 ({ const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (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 visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item => const moreMenu = menu.filter (item =>
!(item.visible ?? true) !(item.visible ?? true)
+5 -3
ファイルの表示
@@ -1,12 +1,14 @@
import React from 'react' import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { children: React.ReactNode } type Props = { children: React.ReactNode; className?: string }
const PageTitle: FC<Props> = ({ children }) => ( const PageTitle: FC<Props> = ({ children, className, ...rest }) => (
<h1 className="text-2xl font-bold mb-2"> <h1 className={cn ('text-2xl font-bold mb-2', className)} {...rest}>
{children} {children}
</h1>) </h1>)
+19 -19
ファイルの表示
@@ -1,19 +1,19 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className )} className)}
ref={ref} ref={ref}
{...props} {...props}
/> ) />)
} ) })
Input.displayName = "Input" Input.displayName = 'Input'
export { Input } export { Input }
+26 -26
ファイルの表示
@@ -1,26 +1,26 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as SwitchPrimitives from "@radix-ui/react-switch" import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", 'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className )} className)}
{...props} {...props}
ref={ref} ref={ref}
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" )} 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0')}
/> />
</SwitchPrimitives.Root> )) </SwitchPrimitives.Root>))
Switch.displayName = SwitchPrimitives.Root.displayName Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch } export { Switch }
+60 -28
ファイルの表示
@@ -1,48 +1,49 @@
import { import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
apiGet,
isApiError,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api'
import type { import type { Material,
Material, MaterialIndexResponse,
FetchMaterialsParams, MaterialVersion,
MaterialFilter, FetchMaterialsParams,
MaterialSidebarTag, MaterialFilter,
MaterialTagTree, MaterialSyncSuppression,
} from '@/types' MaterialSidebarTag,
MaterialTagTree } from '@/types'
export type FetchMaterialTreeParams = { export type FetchMaterialTreeParams = {
parentId?: number | null parentId?: number | null
materialFilter: MaterialFilter materialFilter: MaterialFilter }
}
export type MaterialIndexResponse = { export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] }
materials: Material[]
count: number export type MaterialChangesResponse = {
} versions: MaterialVersion[]
count: number }
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
export const parseMaterialFilter = ( export const parseMaterialFilter = (
value: unknown, value: unknown,
fallback: MaterialFilter = 'present'): MaterialFilter => fallback: MaterialFilter = 'present',
): MaterialFilter =>
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
? value as MaterialFilter ? value as MaterialFilter
: fallback : fallback
export const fetchMaterials = async ( export const fetchMaterials = async (
{ q, tagState, mediaKind, suppression, createdFrom, createdTo, { q, tagState, mediaKind, createdFrom, createdTo,
updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams): Promise<MaterialIndexResponse> => updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams,
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: { await apiGet ('/materials', { params: {
...(q && { q }), ...(q && { q }),
tag_state: tagState, tag_state: tagState,
media_kind: mediaKind, media_kind: mediaKind,
suppression, ...(tagId != null && { tag_id: tagId }),
...(includeDescendants && { include_descendants: '1' }),
...(groupBy !== 'none' && { group_by: groupBy }),
...(createdFrom && { created_from: createdFrom }), ...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }), ...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }), ...(updatedFrom && { updated_from: updatedFrom }),
@@ -67,6 +68,22 @@ export const fetchMaterial = async (id: string): Promise<Material | null> => {
} }
export const fetchMaterialChanges = async (
{ materialId, tag, eventType, page, limit }: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { params: {
...(materialId && { material_id: materialId }),
...(tag && { tag }),
...(eventType && { event_type: eventType }),
page,
limit} })
export const fetchMaterialTagTree = async ( export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> => { parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: { await apiGet ('/tags/with-depth', { params: {
@@ -101,7 +118,22 @@ export const updateMaterial = async (
await apiPut (`/materials/${ id }`, formData) await apiPut (`/materials/${ id }`, formData)
export const suppressMaterialFile = async ( export const fetchMaterialSyncSuppressions =
id: string, async (): Promise<MaterialSyncSuppressionResponse> =>
payload: { reason: string; purge?: boolean }): Promise<Material> => await apiGet ('/materials/suppressions')
await apiPatch (`/materials/${ id }/suppress_file`, payload)
export const createMaterialSyncSuppression = async (
payload: {
sourceKind: string
sourceUri?: string
drivePath?: string
driveFileId?: string
reason: string
}): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind,
source_uri: payload.sourceUri,
drive_path: payload.drivePath,
drive_file_id: payload.driveFileId,
reason: payload.reason})
+8
ファイルの表示
@@ -34,9 +34,17 @@ export const tagsKeys = {
export const materialsKeys = { export const materialsKeys = {
root: ['materials'] as const, root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
changes: (p: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}) => ['materials', 'changes', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) => byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const, ['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const, show: (id: string) => ['materials', id] as const,
suppressions: () => ['materials', 'suppressions'] as const,
tree: (p: { tree: (p: {
parentId?: number | null parentId?: number | null
materialFilter: MaterialFilter materialFilter: MaterialFilter
+26 -4
ファイルの表示
@@ -7,9 +7,9 @@ import { buildMaterial, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPatch: vi.fn (), apiPut: vi.fn (),
apiPut: vi.fn (), isApiError: vi.fn (),
})) }))
const wikiApi = vi.hoisted (() => ({ const wikiApi = vi.hoisted (() => ({
@@ -35,7 +35,11 @@ const renderPage = () =>
describe ('MaterialDetailPage', () => { describe ('MaterialDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([]) api.apiGet.mockImplementation (async (path: string) =>
path === '/tags/autocomplete'
? []
: buildMaterial ({ id: 8 }))
api.isApiError.mockReturnValue (false)
wikiApi.fetchWikiPages.mockResolvedValue ([]) wikiApi.fetchWikiPages.mockResolvedValue ([])
vi.stubGlobal ('fetch', vi.fn (async () => ({ vi.stubGlobal ('fetch', vi.fn (async () => ({
blob: async () => new Blob (['image'], { type: 'image/png' }), blob: async () => new Blob (['image'], { type: 'image/png' }),
@@ -86,4 +90,22 @@ describe ('MaterialDetailPage', () => {
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
}) })
it ('shows not found when material API returns 404', async () => {
api.isApiError.mockReturnValue (true)
api.apiGet.mockRejectedValueOnce ({ response: { status: 404 } })
renderPage ()
expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument ()
})
it ('shows an error for non-404 material API failures', async () => {
api.isApiError.mockReturnValue (false)
api.apiGet.mockRejectedValueOnce (new Error ('network error'))
renderPage ()
expect (await screen.findByText ('素材の取得に失敗しました.')).toBeInTheDocument ()
})
}) })
+6 -55
ファイルの表示
@@ -15,7 +15,6 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { import {
suppressMaterialFile,
fetchMaterial, fetchMaterial,
updateMaterial, updateMaterial,
} from '@/lib/materials' } from '@/lib/materials'
@@ -25,12 +24,10 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
import type { User } from '@/types'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { const MaterialDetailPage: FC = () => {
const { id } = useParams () const { id } = useParams ()
const qc = useQueryClient () const qc = useQueryClient ()
@@ -91,35 +88,11 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
toast ({ title: '更新失敗……', description: '入力を見直してください.' }) toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}}) }})
const suppressMutation = useMutation ({
mutationFn: async (reason: string) =>
await suppressMaterialFile (id ?? '', { reason }),
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
setFile (null)
setFilePreview ('')
await invalidateMaterialQueries ()
toast ({ title: '抑止しました' })
},
onError: () => {
toast ({ title: '抑止に失敗しました' })
}})
const handleSubmit = () => { const handleSubmit = () => {
clearValidationErrors () clearValidationErrors ()
updateMutation.mutate () updateMutation.mutate ()
} }
const handleSuppress = () => {
const reason = window.prompt ('抑止理由を入力してください。')
if (reason == null || reason.trim () === '')
return
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
return
suppressMutation.mutate (reason)
}
return ( return (
<MainArea> <MainArea>
{material && ( {material && (
@@ -145,16 +118,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
: materialTitle} : materialTitle}
</PageTitle> </PageTitle>
{material.fileSuppressedAt && ( {(material.file && material.contentType) && (
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3
text-red-900 dark:border-red-800 dark:bg-red-950
dark:text-red-100">
<span></span>
{material.fileSuppressionReason && (
<span> : {material.fileSuppressionReason}</span>)}
</div>)}
{(!material.fileSuppressedAt && material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && ( (/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag?.name || undefined}/>)) <img src={material.file} alt={material.tag?.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && ( || (/video\/.*/.test (material.contentType) && (
@@ -163,17 +127,12 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
<audio src={material.file} controls/>)))} <audio src={material.file} controls/>)))}
<TabGroup> <TabGroup>
<Tab name="Wiki"> {material.tag && (
{material.tag <Tab name="Wiki">
? (
<WikiBody <WikiBody
title={material.tag.name} title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>) body={material.wikiPageBody ?? undefined}/>
: ( </Tab>)}
<p className="text-stone-700 dark:text-stone-300">
</p>)}
</Tab>
<Tab name="編輯"> <Tab name="編輯">
<div className="max-w-wl space-y-4 pt-2"> <div className="max-w-wl space-y-4 pt-2">
@@ -256,14 +215,6 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
disabled={updateMutation.isPending}> disabled={updateMutation.isPending}>
</Button> </Button>
{user?.role === 'admin' && !material.fileSuppressedAt && (
<Button
type="button"
variant="destructive"
onClick={handleSuppress}
disabled={suppressMutation.isPending}>
</Button>)}
</div> </div>
</div> </div>
</Tab> </Tab>
+189
ファイルの表示
@@ -0,0 +1,189 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
const materialId = query.get ('material_id') ?? ''
const tag = query.get ('tag') ?? ''
const eventType = query.get ('event_type') ?? ''
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const [materialIdInput, setMaterialIdInput] = useState (materialId)
const [tagInput, setTagInput] = useState (tag)
const [eventTypeInput, setEventTypeInput] = useState (eventType)
useEffect (() => {
setMaterialIdInput (materialId)
setTagInput (tag)
setEventTypeInput (eventType)
}, [eventType, materialId, tag])
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.changes ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }),
queryFn: () => fetchMaterialChanges ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }) })
const versions = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
const handleSearch = (event: FormEvent) => {
event.preventDefault ()
const qs = new URLSearchParams ()
if (materialIdInput.trim ())
qs.set ('material_id', materialIdInput.trim ())
if (tagInput.trim ())
qs.set ('tag', tagInput.trim ())
if (eventTypeInput.trim ())
qs.set ('event_type', eventTypeInput.trim ())
qs.set ('page', '1')
qs.set ('limit', String (limit))
navigate (`/materials/changes?${ qs.toString () }`)
}
return (
<MainArea>
<Helmet>
<title>{`素材履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="space-y-5">
<PageTitle></PageTitle>
<form
onSubmit={handleSearch}
className="max-w-3xl rounded-lg border border-stone-200 bg-white p-4
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="素材 ID">
{({ invalid }) => (
<input
type="text"
value={materialIdInput}
onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ名">
{({ invalid }) => (
<input
type="text"
value={tagInput}
onChange={e => setTagInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
</div>
<button
type="submit"
className="mt-4 rounded-full border border-stone-300 bg-white px-4 py-2
text-sm text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</button>
</form>
{isLoading
? 'Loading...'
: isError
? (
<p className="text-red-600 dark:text-red-300">
</p>)
: (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
<colgroup>
<col className="w-48"/>
<col className="w-28"/>
<col className="w-24"/>
<col className="w-64"/>
<col className="w-64"/>
<col className="w-80"/>
<col className="w-80"/>
<col className="w-48"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"> ID</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left">URL</th>
<th className="p-2 text-left">export_path</th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{versions.map (version => (
<tr
key={version.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">{dateString (version.createdAt)}</td>
<td className="p-2">
<PrefetchLink to={`/materials/${ version.materialId }`}>
#{version.materialId}
</PrefetchLink>
</td>
<td className="p-2">{version.versionNo}</td>
<td className="p-2 break-all">{version.tagName}</td>
<td className="p-2 break-all">{version.fileFilename}</td>
<td className="p-2 break-all">{version.url}</td>
<td className="p-2 break-all">
{version.exportPathsJson.legacy_drive}
</td>
<td className="p-2">
{version.createdByUser
? version.createdByUser.name || `#${ version.createdByUser.id }`
: 'bot 操作'}
</td>
</tr>))}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</>)}
</div>
</MainArea>)
}
export default MaterialHistoryPage
+151 -33
ファイルの表示
@@ -1,5 +1,5 @@
import { screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
import { buildMaterial, buildTag } from '@/test/factories' import { buildMaterial, buildTag } from '@/test/factories'
@@ -12,51 +12,169 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
describe ('MaterialListPage', () => { describe ('MaterialListPage', () => {
it ('shows the empty selection guide without a tag query', () => { beforeEach (() => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' }) vi.clearAllMocks ()
api.apiGet.mockResolvedValue ({ materials: [], count: 0 })
expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute (
'href',
'/materials/new',
)
}) })
it ('loads materials for a tag query', async () => { it ('loads the material index on the initial page', async () => {
const tag = { renderWithProviders (<MaterialListPage/>, { route: '/materials' })
...buildTag ({
id: 4,
name: '素材タグ',
category: 'material',
}),
material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }),
children: [],
}
api.apiGet.mockResolvedValueOnce (tag)
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' })
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/name/%E7%B4%A0%E6%9D%90/materials', '/materials',
{ params: {
tag_state: 'all',
media_kind: 'all',
sort: 'created_at',
direction: 'desc',
page: 1,
limit: 20,
} },
) )
}) })
expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument () expect (await screen.findByText ('素材はありません。')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8')
}) })
it ('offers adding a missing non-meme material', async () => { it ('shows materials in the default card view', async () => {
api.apiGet.mockResolvedValueOnce ({ api.apiGet.mockResolvedValueOnce ({
...buildTag ({ name: '未登録', category: 'material' }), materials: [
material: null, buildMaterial ({
children: [], id: 8,
tag: buildTag ({ name: '素材タグ', category: 'material' }),
thumbnail: 'thumb.png',
mediaKind: 'image',
contentType: 'image/png',
}),
],
count: 1,
}) })
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=x' }) renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute ( expect ((await screen.findByText ('素材タグ')).closest ('a'))
.toHaveAttribute ('href', '/materials/8')
expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument ()
})
it ('submits list filters as material index params', async () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
fireEvent.change (screen.getByPlaceholderText ('タグ名 / URL / ファイル名'), {
target: { value: '虹夏' },
})
const selects = screen.getAllByRole ('combobox')
fireEvent.change (selects[0], {
target: { value: 'untagged' },
})
fireEvent.change (selects[1], {
target: { value: 'image' },
})
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/materials',
expect.objectContaining ({ params: expect.objectContaining ({
q: '虹夏',
tag_state: 'untagged',
media_kind: 'image',
}) }),
)
})
})
it ('keeps a route back to the material search top for untagged materials', async () => {
renderWithProviders (
<MaterialListPage/>,
{ route: '/materials?tag_state=untagged&material_filter=any' },
)
expect (await screen.findByRole (
'link',
{ name: '素材検索トップへ戻る' },
)).toHaveAttribute ('href', '/materials?material_filter=any')
})
it ('uses list view for detailed material rows', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [
buildMaterial ({
id: 9,
tag: buildTag ({ name: '詳細素材', category: 'material' }),
mediaKind: 'file_other',
fileByteSize: 2048,
}),
],
count: 1,
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?view=list' })
expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href', 'href',
'/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2', '/materials/9',
)
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
})
it ('shows the selected tag scope and tag-specific add links', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [
buildMaterial ({
id: 10,
tag: buildTag ({ id: 30, name: '泣き', category: 'material' }),
}),
],
count: 1,
tagScope: {
tag: { id: 20, name: '伊地知ニジカ', category: 'material' },
includeDescendants: true,
},
groups: [
{
key: 'tag:30',
tag: { id: 30, name: '泣き', category: 'material' },
materialIds: [10],
count: 1,
},
],
})
renderWithProviders (
<MaterialListPage/>,
{ route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' },
)
expect (await screen.findByText (
(_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中',
)).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
'href',
'/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present',
)
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
)
})
it ('shows a clear link when tag_id does not resolve to tag_scope', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [],
count: 0,
tagScope: null,
groups: [],
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag_id=999' })
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
) )
}) })
}) })
+212 -86
ファイルの表示
@@ -10,30 +10,30 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { import type { FetchMaterialsParams,
FetchMaterialsParams, Material,
Material, MaterialFilter,
MaterialIndexDirection, MaterialIndexGroup,
MaterialIndexMediaKind, MaterialIndexGroupBy,
MaterialIndexSort, MaterialIndexDirection,
MaterialIndexSuppression, MaterialIndexMediaKind,
MaterialIndexTagState, MaterialIndexSort,
MaterialIndexView } from '@/types' MaterialIndexTagState,
MaterialIndexView } from '@/types'
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = { const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像', image: '画像',
video: '動画', video: '動画',
audio: '音声', audio: '音声',
file_other: 'その他ファイル', file_other: 'その他ファイル',
url_only: 'URL のみ', url_only: 'URL のみ'}
suppressed: '抑止済み'}
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = { const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて', all: 'すべて',
@@ -41,16 +41,16 @@ const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
video: '動画', video: '動画',
audio: '音声', audio: '音声',
file_other: 'その他ファイル', file_other: 'その他ファイル',
url_only: 'URL のみ'} url_only: '外部リンクのみ'}
const SORT_LABELS: Record<MaterialIndexSort, string> = { const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時', created_at: '作成日時',
updated_at: '更新日時', updated_at: '更新日時',
tag_name: 'タグ名', tag_name: 'タグ名',
media_kind: '種類', media_kind: '種類',
file_byte_size: 'ファイルサイズ', file_byte_size: '容量',
version_no: 'バージョン', version_no: '',
id: 'ID'} id: 'Id.'}
const setIf = (qs: URLSearchParams, key: string, value: string | null) => { const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
@@ -81,19 +81,44 @@ const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }` material.tag?.name ?? `素材 #${ material.id }`
const groupedTagPath = (
tagId: number,
materialFilter: MaterialFilter,
): string =>
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ `&material_filter=${ materialFilter }`
const materialNewPath = (
tagName: string,
returnTo: string,
): string =>
`/materials/new?tag=${ encodeURIComponent (tagName) }`
+ `&return_to=${ encodeURIComponent (returnTo) }`
const clearedTagSelectionPath = (
locationSearch: string,
materialFilter: MaterialFilter,
): string => {
const qs = new URLSearchParams (locationSearch)
qs.delete ('tag_id')
qs.delete ('include_descendants')
qs.delete ('group_by')
qs.delete ('page')
qs.set ('material_filter', materialFilter)
return `/materials?${ qs.toString () }`
}
const MaterialThumb: FC<{ material: Material }> = ({ material }) => ( const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div <div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
overflow-hidden rounded-lg border text-center shadow-sm ${ overflow-hidden rounded-lg border border-stone-200 bg-white text-center
material.fileSuppressedAt text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
? [ dark:text-stone-100`}>
'border-red-300 bg-red-50 text-red-900 dark:border-red-800',
'dark:bg-red-950 dark:text-red-100'].join (' ')
: [
'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
{material.thumbnail {material.thumbnail
? <img src={material.thumbnail} alt="" className="h-full w-full object-contain"/> ? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: ( : (
<span <span
className="px-2 text-2xl leading-tight" className="px-2 text-2xl leading-tight"
@@ -123,14 +148,8 @@ const MaterialCard: FC<{ material: Material }> = ({ material }) => (
const MaterialListItem: FC<{ material: Material }> = ({ material }) => ( const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
<article <article
className={`rounded-lg border p-3 shadow-sm ${ className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
material.fileSuppressedAt shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
? [
'border-red-200 bg-red-50 text-red-900 dark:border-red-900',
'dark:bg-red-950 dark:text-red-100'].join (' ')
: [
'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ')}`}>
<div className="flex gap-3"> <div className="flex gap-3">
<MaterialThumb material={material}/> <MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
@@ -141,8 +160,6 @@ const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
dark:text-sky-300"> dark:text-sky-300">
{materialTitle (material)} {materialTitle (material)}
</PrefetchLink> </PrefetchLink>
{material.fileSuppressedAt && (
<p className="mt-1 text-sm text-red-700 dark:text-red-200"></p>)}
</div> </div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300"> <dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div> <div>
@@ -169,6 +186,28 @@ const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
</article>) </article>)
const GroupHeading: FC<{
count: number
materialFilter: MaterialFilter
title: string
tagId: number
}> = ({ count, materialFilter, title, tagId }) => (
<div className="flex items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300 w-full">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-nowrap text-sm text-stone-600 dark:text-stone-300">
{count}
</span>
</div>
</div>)
const MaterialListPage: FC = () => { const MaterialListPage: FC = () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -177,7 +216,10 @@ const MaterialListPage: FC = () => {
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20) const limit = Number (query.get ('limit') ?? 20)
const qQuery = query.get ('q') ?? query.get ('tag') ?? '' const qQuery = query.get ('q') ?? ''
const tagIdQuery = Number (query.get ('tag_id') ?? 0)
const tagId = tagIdQuery > 0 ? tagIdQuery : null
const includeDescendants = query.get ('include_descendants') === '1'
const tagState = query.get ('unclassified') === '1' const tagState = query.get ('unclassified') === '1'
? 'untagged' ? 'untagged'
: parseOption<MaterialIndexTagState> ( : parseOption<MaterialIndexTagState> (
@@ -188,10 +230,6 @@ const MaterialListPage: FC = () => {
query.get ('media_kind'), query.get ('media_kind'),
['all', 'image', 'video', 'audio', 'file_other', 'url_only'], ['all', 'image', 'video', 'audio', 'file_other', 'url_only'],
'all') 'all')
const suppression = parseOption<MaterialIndexSuppression> (
query.get ('suppression'),
['active', 'suppressed', 'all'],
'active')
const sort = parseOption<MaterialIndexSort> ( const sort = parseOption<MaterialIndexSort> (
query.get ('sort'), query.get ('sort'),
['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size', ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
@@ -201,6 +239,11 @@ const MaterialListPage: FC = () => {
query.get ('direction'), query.get ('direction'),
['asc', 'desc'], ['asc', 'desc'],
'desc') 'desc')
const groupByQuery = parseOption<MaterialIndexGroupBy> (
query.get ('group_by'),
['none', 'parent_tag'],
'none')
const groupBy = tagId == null ? 'none' : groupByQuery
const view = parseOption<MaterialIndexView> (query.get ('view'), ['card', 'list'], 'card') const view = parseOption<MaterialIndexView> (query.get ('view'), ['card', 'list'], 'card')
const createdFromQuery = query.get ('created_from') ?? '' const createdFromQuery = query.get ('created_from') ?? ''
const createdToQuery = query.get ('created_to') ?? '' const createdToQuery = query.get ('created_to') ?? ''
@@ -210,8 +253,7 @@ const MaterialListPage: FC = () => {
const [q, setQ] = useState ('') const [q, setQ] = useState ('')
const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all') const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all')
const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all') const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all')
const [suppressionInput, setSuppressionInput] = const [groupByInput, setGroupByInput] = useState<MaterialIndexGroupBy> ('none')
useState<MaterialIndexSuppression> ('active')
const [createdFrom, setCreatedFrom] = useState<string | null> (null) const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -221,7 +263,9 @@ const MaterialListPage: FC = () => {
q: qQuery, q: qQuery,
tagState, tagState,
mediaKind, mediaKind,
suppression, tagId,
includeDescendants,
groupBy,
createdFrom: createdFromQuery, createdFrom: createdFromQuery,
createdTo: createdToQuery, createdTo: createdToQuery,
updatedFrom: updatedFromQuery, updatedFrom: updatedFromQuery,
@@ -234,19 +278,33 @@ const MaterialListPage: FC = () => {
const { data, isLoading, isError } = useQuery ({ const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys), queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)}) queryFn: () => fetchMaterials (keys)})
const tagScope = data?.tagScope ?? null
const materials = data?.materials ?? [] const materials = data?.materials ?? []
const groups = data?.groups ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
const materialsById = useMemo (
() => new Map (materials.map (material => [material.id, material])),
[materials],
)
const groupedMaterialIds = useMemo (
() => new Set (groups.flatMap (group => group.materialIds)),
[groups],
)
const ungroupedMaterials = useMemo (
() => materials.filter (material => !groupedMaterialIds.has (material.id)),
[groupedMaterialIds, materials],
)
useEffect (() => { useEffect (() => {
setQ (qQuery) setQ (qQuery)
setTagStateInput (tagState) setTagStateInput (tagState)
setMediaKindInput (mediaKind) setMediaKindInput (mediaKind)
setSuppressionInput (suppression) setGroupByInput (groupBy)
setCreatedFrom (createdFromQuery) setCreatedFrom (createdFromQuery)
setCreatedTo (createdToQuery) setCreatedTo (createdToQuery)
setUpdatedFrom (updatedFromQuery) setUpdatedFrom (updatedFromQuery)
setUpdatedTo (updatedToQuery) setUpdatedTo (updatedToQuery)
}, [createdFromQuery, createdToQuery, mediaKind, qQuery, suppression, tagState, }, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState,
updatedFromQuery, updatedToQuery]) updatedFromQuery, updatedToQuery])
const search = (e: FormEvent) => { const search = (e: FormEvent) => {
@@ -256,26 +314,83 @@ const MaterialListPage: FC = () => {
setIf (qs, 'q', q) setIf (qs, 'q', q)
qs.set ('tag_state', tagStateInput) qs.set ('tag_state', tagStateInput)
qs.set ('media_kind', mediaKindInput) qs.set ('media_kind', mediaKindInput)
qs.set ('suppression', suppressionInput)
setIf (qs, 'created_from', createdFrom) setIf (qs, 'created_from', createdFrom)
setIf (qs, 'created_to', createdTo) setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom) setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo) setIf (qs, 'updated_to', updatedTo)
qs.set ('sort', sort) qs.set ('sort', sort)
qs.set ('direction', direction) qs.set ('direction', direction)
qs.set ('group_by', tagId == null ? 'none' : groupByInput)
qs.set ('view', view) qs.set ('view', view)
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('limit', String (limit)) qs.set ('limit', String (limit))
qs.set ('material_filter', materialFilter) qs.set ('material_filter', materialFilter)
if (tagId != null)
qs.set ('tag_id', String (tagId))
if (includeDescendants)
qs.set ('include_descendants', '1')
navigate (`/materials?${ qs.toString () }`) navigate (`/materials?${ qs.toString () }`)
} }
const updateQuery = (changes: Record<string, string>) => { const updateQuery = (changes: Record<string, string>) => {
const qs = new URLSearchParams (location.search) const qs = new URLSearchParams (location.search)
Object.entries (changes).forEach (([key, value]) => qs.set (key, value)) Object.entries (changes).forEach (([key, value]) => {
if (value === '')
qs.delete (key)
else
qs.set (key, value)
})
navigate (`/materials?${ qs.toString () }`) navigate (`/materials?${ qs.toString () }`)
} }
const renderMaterialCollection = (rows: Material[]) =>
view === 'card'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{rows.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{rows.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>)
const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => (
<div className="space-y-6">
{materialGroups.map (group => {
const groupMaterials =
group.materialIds
.map (materialId => materialsById.get (materialId))
.filter ((material): material is Material => material != null)
if (groupMaterials.length === 0)
return null
return (
<section key={group.key} className="space-y-3">
<GroupHeading
tagId={group.tag.id}
title={group.tag.name}
count={group.count}
materialFilter={materialFilter}/>
{renderMaterialCollection (groupMaterials)}
</section>)
})}
{ungroupedMaterials.length > 0 && (
<section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100">
</span>
</div>
{renderMaterialCollection (ungroupedMaterials)}
</section>)}
</div>)
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
@@ -287,21 +402,15 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype'); src: url(${ nikumaru }) format('opentype');
}`} }`}
</style> </style>
<title>{`素材一覧 | ${ SITE_TITLE }`}</title> <title>{`素材管理 | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle> <PageTitle className="my-auto"></PageTitle>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PrefetchLink {/* TODO: 局所出力を可能にする */}
to="/materials/new" {/* <a
className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</PrefetchLink>
<a
href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`} href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -309,10 +418,38 @@ const MaterialListPage: FC = () => {
text-stone-900 hover:bg-stone-100 dark:border-stone-700 text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800"> dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
ZIP ZIP
</a> </a> */}
</div> </div>
</div> </div>
{tagScope && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span>
{tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
</div>)}
{(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span></span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200">
</PrefetchLink>
</div>)}
{tagState === 'untagged' && ( {tagState === 'untagged' && (
<PrefetchLink <PrefetchLink
to={`/materials?material_filter=${ materialFilter }`} to={`/materials?material_filter=${ materialFilter }`}
@@ -363,19 +500,6 @@ const MaterialListPage: FC = () => {
</select>)} </select>)}
</FormField> </FormField>
<FormField label="抑止状態">
{({ invalid }) => (
<select
value={suppressionInput}
onChange={e => setSuppressionInput (
e.target.value as MaterialIndexSuppression)}
className={inputClass (invalid)}>
<option value="active"></option>
<option value="suppressed"></option>
<option value="all"></option>
</select>)}
</FormField>
<FormField label="作成日時"> <FormField label="作成日時">
{() => ( {() => (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -418,7 +542,7 @@ const MaterialListPage: FC = () => {
: [ : [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button> </button>
<button <button
type="button" type="button"
@@ -431,7 +555,7 @@ const MaterialListPage: FC = () => {
: [ : [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button> </button>
</div> </div>
@@ -459,20 +583,22 @@ const MaterialListPage: FC = () => {
{isError && ( {isError && (
<p className="text-red-600 dark:text-red-300"></p>)} <p className="text-red-600 dark:text-red-300"></p>)}
{(!isLoading && !isError && materials.length === 0) && ( {(!isLoading && !isError && materials.length === 0) && (
<p></p>)} <p>
{(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
<>
<PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
</>)}
</p>)}
{materials.length > 0 && ( {materials.length > 0 && (
view === 'card' groupBy === 'parent_tag' && groups.length > 0
? ( ? renderGroupedMaterials (groups)
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))] : renderMaterialCollection (materials))}
justify-items-center gap-4">
{materials.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{materials.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>))}
<Pagination page={page} totalPages={totalPages}/> <Pagination page={page} totalPages={totalPages}/>
</div> </div>
</MainArea>) </MainArea>)
+52
ファイルの表示
@@ -1,5 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
const LocationView = () => {
const location = useLocation ()
return <div>{location.pathname + location.search}</div>
}
describe ('MaterialNewPage', () => { describe ('MaterialNewPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2) expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true') expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
}) })
it ('returns only to safe material URLs after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
})
it ('ignores unsafe return_to values after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
})
}) })
+3 -1
ファイルの表示
@@ -27,6 +27,8 @@ const MaterialNewPage: FC = () => {
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? '' const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate () const navigate = useNavigate ()
@@ -54,7 +56,7 @@ const MaterialNewPage: FC = () => {
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root }) await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' }) toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`) navigate (safeReturnTo || '/materials')
}, },
onError: error => { onError: error => {
applyValidationError (error) applyValidationError (error)
-51
ファイルの表示
@@ -1,51 +0,0 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import type { FC, FormEvent } from 'react'
const MaterialSearchPage: FC = () => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<FormField label="タグ">
{() => (
<TagInput
value={tagName}
setValue={setTagName}/>)}
</FormField>
{/* 親タグ */}
<FormField label="親タグ">
{() => (
<TagInput
value={parentTagName}
setValue={setParentTagName}/>)}
</FormField>
</form>
</div>
</MainArea>)
}
export default MaterialSearchPage
+202
ファイルの表示
@@ -0,0 +1,202 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
import type { MaterialSyncSuppressionSourceKind } from '@/types'
const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = {
uri: 'URI',
google_drive_path: 'Google Drive ファイル',
google_drive_path_prefix: 'Google Drive フォルダ',
google_drive_file: 'Google Drive ファイル Id.',
legacy_drive_path: '汎用ファイル',
legacy_drive_path_prefix: '汎用フォルダ' }
const REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'] as const
type MaterialSyncSuppressionReason = typeof REASONS[number]
const REASON_NAMES: Record<MaterialSyncSuppressionReason, string> = {
['copyright_high_risk']: '著作権への懸念',
['copyright_takedown']: '著作者からの申出',
['adult_or_sensitive']: '成人向け',
['personal_information']: '個人情報',
['malware_or_dangerous_file']: '危険なソフトウェア',
['duplicate_or_low_quality']: '重複',
['source_owner_request']: '同期元管理者からの申出',
['other']: 'その他' } as const
const reasonName = (reason: string): string =>
REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
const MaterialSyncSuppressionsPage: FC = () => {
const qc = useQueryClient ()
const [sourceKind, setSourceKind] =
useState<MaterialSyncSuppressionSourceKind> ('uri')
const [sourceUri, setSourceUri] = useState ('')
const [drivePath, setDrivePath] = useState ('')
const [driveFileId, setDriveFileId] = useState ('')
const [reason, setReason] = useState<MaterialSyncSuppressionReason> (REASONS[0])
const { data, isError, isLoading } = useQuery ({
queryKey: materialsKeys.suppressions (),
queryFn: fetchMaterialSyncSuppressions})
const createMutation = useMutation ({
mutationFn: async () =>
await createMaterialSyncSuppression ({
sourceKind,
sourceUri,
drivePath,
driveFileId,
reason}),
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
setSourceUri ('')
setDrivePath ('')
setDriveFileId ('')
toast ({ title: '同期元抑止を登録しました' })
},
onError: () => {
toast ({ title: '同期元抑止の登録に失敗しました' })
}})
const handleSubmit = (event: FormEvent) => {
event.preventDefault ()
createMutation.mutate ()
}
const suppressions = data?.suppressions ?? []
return (
<MainArea>
<Helmet>
<title>{`素材同期抑止 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="space-y-5">
<PageTitle></PageTitle>
<form
onSubmit={handleSubmit}
className="max-w-3xl rounded-lg border border-stone-200 bg-white p-4
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="同期元種別">
{({ invalid }) => (
<select
value={sourceKind}
onChange={e => setSourceKind (
e.target.value as MaterialSyncSuppressionSourceKind)}
className={inputClass (invalid)}>
{Object.entries (SOURCE_KIND_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<FormField label="同期元 URI">
{({ invalid }) => (
<input
type="text"
value={sourceUri}
onChange={e => setSourceUri (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive パス">
{({ invalid }) => (
<input
type="text"
value={drivePath}
onChange={e => setDrivePath (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive ファイル Id.">
{({ invalid }) => (
<input
type="text"
value={driveFileId}
onChange={e => setDriveFileId (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="事由">
{({ invalid }) => (
<select
value={reason}
onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)}
className={inputClass (invalid)}>
{REASONS.map (value => (
<option key={value} value={value}>
{REASON_NAMES[value]}
</option>))}
</select>)}
</FormField>
</div>
<Button
type="submit"
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={createMutation.isPending}>
</Button>
</form>
{isLoading && <p>Loading...</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">
</p>)}
<div className="space-y-3">
{suppressions.map (suppression => (
<article
key={suppression.id}
className="rounded-lg border border-stone-200 bg-white p-3
text-stone-900 shadow-sm dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
<div className="font-medium">
{SOURCE_KIND_LABELS[suppression.sourceKind]}
</div>
<div className="break-all text-sm text-stone-700 dark:text-stone-300">
{suppression.normalizedSourceKey}
</div>
<div className="mt-2 text-sm text-stone-600 dark:text-stone-400">
: {reasonName (suppression.reason)} /
: {dateString (suppression.createdAt)}
</div>
</article>))}
</div>
</div>
</MainArea>)
}
export default MaterialSyncSuppressionsPage
+5 -2
ファイルの表示
@@ -76,6 +76,11 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
tag: buildTag (), tag: buildTag (),
file: null, file: null,
url: null, url: null,
sourceKind: null,
sourceUri: null,
sourcePath: null,
sourceFileId: null,
normalizedSourceKey: null,
wikiPageBody: null, wikiPageBody: null,
thumbnail: null, thumbnail: null,
thumbnailFallbackText: 'テストタグ', thumbnailFallbackText: 'テストタグ',
@@ -83,8 +88,6 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
mediaKind: 'url_only', mediaKind: 'url_only',
contentType: null, contentType: null,
fileByteSize: null, fileByteSize: null,
fileSuppressedAt: null,
fileSuppressionReason: null,
exportPaths: {}, exportPaths: {},
exportItems: [], exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z', createdAt: '2026-01-02T03:04:05.000Z',
+81 -6
ファイルの表示
@@ -86,17 +86,28 @@ export type MaterialIndexSort =
| 'version_no' | 'version_no'
| 'id' | 'id'
export type MaterialIndexSuppression = 'active' | 'suppressed' | 'all'
export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged' export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged'
export type MaterialIndexGroupBy = 'none' | 'parent_tag'
export type MaterialIndexView = 'card' | 'list' export type MaterialIndexView = 'card' | 'list'
export type MaterialTagScope = {
tag: {
id: number
name: string
category: Category
}
includeDescendants: boolean
}
export type FetchMaterialsParams = { export type FetchMaterialsParams = {
q: string q: string
tagState: MaterialIndexTagState tagState: MaterialIndexTagState
mediaKind: MaterialIndexMediaKind mediaKind: MaterialIndexMediaKind
suppression: MaterialIndexSuppression tagId: number | null
includeDescendants: boolean
groupBy: MaterialIndexGroupBy
createdFrom: string createdFrom: string
createdTo: string createdTo: string
updatedFrom: string updatedFrom: string
@@ -113,15 +124,18 @@ export type Material = {
tag: Tag | null tag: Tag | null
file: string | null file: string | null
url: string | null url: string | null
sourceKind: string | null
sourceUri: string | null
sourcePath: string | null
sourceFileId: string | null
normalizedSourceKey: string | null
wikiPageBody?: string | null wikiPageBody?: string | null
thumbnail: string | null thumbnail: string | null
thumbnailFallbackText: string | null thumbnailFallbackText: string | null
thumbnailFallbackKind: 'tag_name' | 'created_at' thumbnailFallbackKind: 'tag_name' | 'created_at'
mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only' | 'suppressed' mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only'
contentType: string | null contentType: string | null
fileByteSize: number | null fileByteSize: number | null
fileSuppressedAt: string | null
fileSuppressionReason: string | null
exportPaths: Record<string, string> exportPaths: Record<string, string>
exportItems: MaterialExportItem[] exportItems: MaterialExportItem[]
createdAt: string createdAt: string
@@ -129,6 +143,26 @@ export type Material = {
updatedAt: string updatedAt: string
updatedByUser: { id: number; name: string } } updatedByUser: { id: number; name: string } }
export type MaterialSyncSuppressionSourceKind =
| 'uri'
| 'google_drive_path'
| 'google_drive_path_prefix'
| 'google_drive_file'
| 'legacy_drive_path'
| 'legacy_drive_path_prefix'
export type MaterialSyncSuppression = {
id: number
sourceKind: MaterialSyncSuppressionSourceKind
sourceUri: string | null
drivePath: string | null
driveFileId: string | null
normalizedSourceKey: string
reason: string
createdByUser: { id: number; name: string } | null
createdAt: string
updatedAt: string }
export type MaterialSidebarTag = { export type MaterialSidebarTag = {
id: number id: number
name: string name: string
@@ -150,6 +184,47 @@ export type MaterialExportItem = {
exportPath: string exportPath: string
enabled: boolean } enabled: boolean }
export type MaterialIndexGroup = {
key: string
tag: {
id: number
name: string
category: Category
}
materialIds: number[]
count: number }
export type MaterialIndexResponse = {
materials: Material[]
count: number
groups?: MaterialIndexGroup[]
tagScope?: MaterialTagScope | null
}
export type MaterialVersion = {
id: number
materialId: number
versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore'
tagId: number | null
tagName: string | null
tagCategory: Category | null
url: string | null
sourceKind: string | null
sourceUri: string | null
sourcePath: string | null
sourceFileId: string | null
normalizedSourceKey: string | null
fileBlobId: number | null
fileFilename: string | null
fileContentType: string | null
fileByteSize: number | null
fileSha256: string | null
exportPathsJson: Record<string, string>
discardedAt: string | null
createdByUser: { id: number; name: string | null } | null
createdAt: string }
export type Menu = MenuItem[] export type Menu = MenuItem[]
export type MenuInvisibleItem = { export type MenuInvisibleItem = {