開発環境では、**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>
このコミットはPull リクエスト #381 でマージされました.
このコミットが含まれているのは:
+6
-12
@@ -23,8 +23,7 @@ const apiP = async <T> (
|
||||
method: 'post' | 'put' | 'patch',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client[method] (path, body ?? { }, withUserCode (opt))
|
||||
if (opt?.responseType === 'blob')
|
||||
return res.data as T
|
||||
@@ -34,8 +33,7 @@ const apiP = async <T> (
|
||||
|
||||
export const apiGet = async <T> (
|
||||
path: string,
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client.get (path, withUserCode (opt))
|
||||
if (opt?.responseType === 'blob')
|
||||
return res.data as T
|
||||
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
|
||||
export const apiPost = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('post', path, body, opt)
|
||||
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
|
||||
|
||||
|
||||
export const apiPut = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('put', path, body, opt)
|
||||
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
|
||||
|
||||
|
||||
export const apiPatch = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt,
|
||||
): Promise<T> => apiP ('patch', path, body, opt)
|
||||
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
|
||||
|
||||
|
||||
export const apiDelete = async <T = void> (
|
||||
path: string,
|
||||
opt?: Opt,
|
||||
): Promise<T> => {
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client.delete (path, withUserCode (opt))
|
||||
if (res.data == null || res.data === '')
|
||||
return undefined as T
|
||||
|
||||
+14
-28
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
|
||||
|
||||
|
||||
export const normalizeTitleLengthCondition = (
|
||||
condition: GekanatorQuestionCondition,
|
||||
): GekanatorQuestionCondition => {
|
||||
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'title-length-greater-than':
|
||||
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
|
||||
|
||||
|
||||
export const titleLengthMinimumForCondition = (
|
||||
condition: GekanatorQuestionCondition,
|
||||
): number | null => {
|
||||
condition: GekanatorQuestionCondition): number | null => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'title-length-at-least':
|
||||
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
|
||||
|
||||
|
||||
export const questionIdForCondition = (
|
||||
condition: NonPostSimilarityCondition,
|
||||
): string => {
|
||||
condition: NonPostSimilarityCondition): string => {
|
||||
switch (condition.type)
|
||||
{
|
||||
case 'tag':
|
||||
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
|
||||
|
||||
const directExampleAnswerFor = (
|
||||
question: StoredGekanatorQuestion,
|
||||
post: Post,
|
||||
): GekanatorAnswerValue | null => {
|
||||
post: Post): GekanatorAnswerValue | null => {
|
||||
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
|
||||
return null
|
||||
|
||||
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
|
||||
|
||||
|
||||
export const isLearnedSemanticQuestion = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion,
|
||||
): boolean =>
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
|
||||
question.kind === 'post_similarity'
|
||||
&& question.source === 'user_suggested'
|
||||
|
||||
|
||||
export const learnedSemanticSideForAnswer = (
|
||||
answer: GekanatorAnswerValue | null,
|
||||
): LearnedSemanticSide => {
|
||||
answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
|
||||
if (answer === 'yes' || answer === 'partial')
|
||||
return 'positive'
|
||||
|
||||
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
|
||||
|
||||
const questionMatches = (
|
||||
post: Post,
|
||||
question: StoredGekanatorQuestion,
|
||||
): boolean => {
|
||||
question: StoredGekanatorQuestion): boolean => {
|
||||
const directAnswer = directExampleAnswerFor (question, post)
|
||||
if (directAnswer)
|
||||
return question.kind === 'post_similarity'
|
||||
@@ -350,8 +343,7 @@ const questionMatches = (
|
||||
|
||||
export const expectedAnswerForQuestion = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||
post: Post | null,
|
||||
): GekanatorAnswerValue | null => {
|
||||
post: Post | null): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(post))
|
||||
return null
|
||||
|
||||
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
|
||||
|
||||
export const learnedSemanticSideForPost = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||
post: Post | null,
|
||||
): LearnedSemanticSide =>
|
||||
post: Post | null): LearnedSemanticSide =>
|
||||
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
|
||||
|
||||
|
||||
export const restoreGekanatorQuestion = (
|
||||
question: StoredGekanatorQuestion,
|
||||
): GekanatorQuestion => {
|
||||
question: StoredGekanatorQuestion): GekanatorQuestion => {
|
||||
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
|
||||
const normalizedQuestion = {
|
||||
...question,
|
||||
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
|
||||
|
||||
|
||||
export const storeGekanatorQuestion = (
|
||||
question: GekanatorQuestion,
|
||||
): StoredGekanatorQuestion => ({
|
||||
question: GekanatorQuestion): StoredGekanatorQuestion => ({
|
||||
id: question.condition.type === 'title-length-greater-than'
|
||||
? `title:length-at-least:${ question.condition.length + 1 }`
|
||||
: question.id,
|
||||
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
|
||||
|
||||
export const fetchGekanatorExtraQuestions = async (
|
||||
gameId: number,
|
||||
nonce?: string,
|
||||
): Promise<GekanatorExtraQuestion[]> => {
|
||||
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
|
||||
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||
`/gekanator/games/${ gameId }/extra_questions`,
|
||||
{ params: nonce ? { nonce } : undefined })
|
||||
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
|
||||
|
||||
export const buildGekanatorQuestions = (
|
||||
posts: Post[],
|
||||
options: BuildGekanatorQuestionsOptions = { },
|
||||
): GekanatorQuestion[] => {
|
||||
options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
|
||||
const {
|
||||
includeTitleContains = true,
|
||||
tagQuestionCap = 192,
|
||||
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
|
||||
|
||||
const usefulEntries = <T extends string | number> (
|
||||
counts: Map<T, number>,
|
||||
cap: number,
|
||||
) =>
|
||||
cap: number) =>
|
||||
[...counts.entries ()]
|
||||
.filter (([, count]) => count > 0 && count < posts.length)
|
||||
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
|
||||
|
||||
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
|
||||
answers: GekanatorAnswerLog[]
|
||||
softenedQuestionIds: Set<string>
|
||||
rejectedPostIds: Set<number>
|
||||
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
|
||||
): Post[] => {
|
||||
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
|
||||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||||
|
||||
return posts.filter (post => {
|
||||
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
|
||||
export const hardFilteredPostsForAnswer = (
|
||||
{ posts, question, answer }: { posts: Post[]
|
||||
question: GekanatorQuestion
|
||||
answer: GekanatorAnswerValue },
|
||||
): Post[] => {
|
||||
answer: GekanatorAnswerValue }): Post[] => {
|
||||
if (!(questionSupportsAnswerBasedHardFiltering (question)))
|
||||
return posts
|
||||
|
||||
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
|
||||
|
||||
export const allConcreteAnswerOptionsExhausted = (
|
||||
posts: Post[],
|
||||
question: GekanatorQuestion | null,
|
||||
): boolean => {
|
||||
question: GekanatorQuestion | null): boolean => {
|
||||
if (!(question))
|
||||
return false
|
||||
|
||||
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
|
||||
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||||
eligiblePostIds: Set<number>
|
||||
answerCountAtRecovery: number
|
||||
recoveryStepCount: number },
|
||||
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||||
recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||||
recoveryStepCount: number } | null => {
|
||||
const recovered = new Map (recoveredCandidatePosts)
|
||||
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
|
||||
|
||||
export const monthForCondition = (
|
||||
condition: GekanatorQuestion['condition'],
|
||||
): number | null => {
|
||||
condition: GekanatorQuestion['condition']): number | null => {
|
||||
if (condition.type === 'original-month')
|
||||
return condition.month
|
||||
|
||||
@@ -24,8 +23,7 @@ export const monthForCondition = (
|
||||
const isTitleLengthContradiction = (
|
||||
candidate: GekanatorQuestion['condition'],
|
||||
previous: GekanatorQuestion['condition'],
|
||||
answer: GekanatorAnswerValue,
|
||||
): boolean => {
|
||||
answer: GekanatorAnswerValue): boolean => {
|
||||
const candidateLength = titleLengthMinimumForCondition (candidate)
|
||||
const previousLength = titleLengthMinimumForCondition (previous)
|
||||
if (candidateLength === null || previousLength === null)
|
||||
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
|
||||
|
||||
const isQuestionRedundantAfterAnswers = (
|
||||
question: GekanatorQuestion,
|
||||
answers: GekanatorAnswerLog[],
|
||||
): boolean => answers.some (answer => {
|
||||
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
|
||||
const previous = answer.questionCondition
|
||||
return previous !== undefined
|
||||
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
|
||||
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
|
||||
const isSourceFactBlocked = (
|
||||
candidate: GekanatorQuestion['condition'],
|
||||
previous: GekanatorQuestion['condition'],
|
||||
answer: GekanatorAnswerValue,
|
||||
): boolean => {
|
||||
answer: GekanatorAnswerValue): boolean => {
|
||||
if (candidate.type !== 'source' || previous.type !== 'source')
|
||||
return false
|
||||
|
||||
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
|
||||
const isOriginalYearFactBlocked = (
|
||||
candidate: GekanatorQuestion['condition'],
|
||||
previous: GekanatorQuestion['condition'],
|
||||
answer: GekanatorAnswerValue,
|
||||
): boolean => {
|
||||
answer: GekanatorAnswerValue): boolean => {
|
||||
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
|
||||
return false
|
||||
|
||||
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
|
||||
const isOriginalMonthFactBlocked = (
|
||||
candidate: GekanatorQuestion['condition'],
|
||||
previous: GekanatorQuestion['condition'],
|
||||
answer: GekanatorAnswerValue,
|
||||
): boolean => {
|
||||
answer: GekanatorAnswerValue): boolean => {
|
||||
switch (answer)
|
||||
{
|
||||
case 'yes':
|
||||
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
|
||||
const isFactQuestionBlocked = (
|
||||
candidate: GekanatorQuestion['condition'],
|
||||
previous: GekanatorQuestion['condition'],
|
||||
answer: GekanatorAnswerValue,
|
||||
): boolean => {
|
||||
answer: GekanatorAnswerValue): boolean => {
|
||||
if (!(answer === 'yes' || answer === 'no'))
|
||||
return false
|
||||
|
||||
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
|
||||
|
||||
export const isQuestionHardFilteredAfterAnswers = (
|
||||
question: GekanatorQuestion,
|
||||
answers: GekanatorAnswerLog[],
|
||||
): boolean => answers.some (answer => {
|
||||
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
|
||||
const previous = answer.questionCondition
|
||||
if (previous === undefined)
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
|
||||
|
||||
import type { Material,
|
||||
MaterialIndexResponse,
|
||||
MaterialVersion,
|
||||
FetchMaterialsParams,
|
||||
MaterialFilter,
|
||||
MaterialSyncSuppression,
|
||||
MaterialSidebarTag,
|
||||
MaterialTagTree } from '@/types'
|
||||
|
||||
export type FetchMaterialTreeParams = {
|
||||
parentId?: number | null
|
||||
materialFilter: MaterialFilter }
|
||||
|
||||
export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] }
|
||||
|
||||
export type MaterialChangesResponse = {
|
||||
versions: MaterialVersion[]
|
||||
count: number }
|
||||
|
||||
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
|
||||
|
||||
|
||||
export const parseMaterialFilter = (
|
||||
value: unknown,
|
||||
fallback: MaterialFilter = 'present',
|
||||
): MaterialFilter =>
|
||||
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
|
||||
? value as MaterialFilter
|
||||
: fallback
|
||||
|
||||
|
||||
export const fetchMaterials = async (
|
||||
{ q, tagState, mediaKind, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, sort, direction, page,
|
||||
tagId, includeDescendants, groupBy,
|
||||
limit }: FetchMaterialsParams,
|
||||
): Promise<MaterialIndexResponse> =>
|
||||
await apiGet ('/materials', { params: {
|
||||
...(q && { q }),
|
||||
tag_state: tagState,
|
||||
media_kind: mediaKind,
|
||||
...(tagId != null && { tag_id: tagId }),
|
||||
...(includeDescendants && { include_descendants: '1' }),
|
||||
...(groupBy !== 'none' && { group_by: groupBy }),
|
||||
...(createdFrom && { created_from: createdFrom }),
|
||||
...(createdTo && { created_to: createdTo }),
|
||||
...(updatedFrom && { updated_from: updatedFrom }),
|
||||
...(updatedTo && { updated_to: updatedTo }),
|
||||
sort,
|
||||
direction,
|
||||
page,
|
||||
limit} })
|
||||
|
||||
|
||||
export const fetchMaterial = async (id: string): Promise<Material | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/materials/${ id }`)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (isApiError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
|
||||
await apiGet ('/tags/with-depth', { params: {
|
||||
...(parentId != null && { parent: String (parentId) }),
|
||||
material_filter: materialFilter} })
|
||||
|
||||
|
||||
export const fetchMaterialTagByName = async (
|
||||
name: string,
|
||||
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
|
||||
{ params: { material_filter: materialFilter } })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (isApiError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const createMaterial = async (formData: FormData): Promise<Material> =>
|
||||
await apiPost ('/materials', formData)
|
||||
|
||||
|
||||
export const updateMaterial = async (
|
||||
id: string,
|
||||
formData: FormData): Promise<Material> =>
|
||||
await apiPut (`/materials/${ id }`, formData)
|
||||
|
||||
|
||||
export const fetchMaterialSyncSuppressions =
|
||||
async (): Promise<MaterialSyncSuppressionResponse> =>
|
||||
await apiGet ('/materials/suppressions')
|
||||
|
||||
|
||||
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})
|
||||
@@ -5,8 +5,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
|
||||
|
||||
export const fetchPosts = async (
|
||||
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
||||
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
|
||||
): Promise<{
|
||||
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
|
||||
posts: Post[]
|
||||
count: number }> =>
|
||||
await apiGet ('/posts', { params: {
|
||||
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
|
||||
post?: string
|
||||
tag?: string
|
||||
page: number
|
||||
limit: number },
|
||||
): Promise<{
|
||||
limit: number }): Promise<{
|
||||
versions: PostVersion[]
|
||||
count: number }> =>
|
||||
await apiGet ('/posts/versions', { params: { ...(post && { post }),
|
||||
@@ -52,8 +50,7 @@ export const updatePost = async (
|
||||
{ baseVersionNo, force, merge }: {
|
||||
baseVersionNo?: number
|
||||
force?: boolean
|
||||
merge?: boolean }
|
||||
) =>
|
||||
merge?: boolean }) =>
|
||||
await apiPut<Post> (
|
||||
`/posts/${ post.id }`,
|
||||
{ title: post.title,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
|
||||
import type {
|
||||
FetchNicoTagsParams,
|
||||
FetchMaterialsParams,
|
||||
FetchPostsParams,
|
||||
FetchTagsParams,
|
||||
MaterialFilter,
|
||||
} from '@/types'
|
||||
|
||||
export const postsKeys = {
|
||||
root: ['posts'] as const,
|
||||
@@ -25,6 +31,27 @@ export const tagsKeys = {
|
||||
['tags', 'changes', p] as const,
|
||||
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
|
||||
|
||||
export const materialsKeys = {
|
||||
root: ['materials'] 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) =>
|
||||
['materials', 'tag', name, materialFilter] as const,
|
||||
show: (id: string) => ['materials', id] as const,
|
||||
suppressions: () => ['materials', 'suppressions'] as const,
|
||||
tree: (p: {
|
||||
parentId?: number | null
|
||||
materialFilter: MaterialFilter
|
||||
}) => ['materials', 'tree', p] as const,
|
||||
unclassified: (p: { page?: number; limit?: number } = { }) =>
|
||||
['materials', 'unclassified', p] as const}
|
||||
|
||||
export const wikiKeys = {
|
||||
root: ['wiki'] as const,
|
||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||
|
||||
@@ -11,8 +11,7 @@ import type { Deerjikist,
|
||||
export const fetchTags = async (
|
||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, deprecated,
|
||||
page, limit, order }: FetchTagsParams,
|
||||
): Promise<{ tags: Tag[]
|
||||
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
|
||||
count: number }> =>
|
||||
await apiGet ('/tags', { params: {
|
||||
...(post != null && { post }),
|
||||
@@ -31,8 +30,7 @@ export const fetchTags = async (
|
||||
|
||||
|
||||
export const fetchNicoTags = async (
|
||||
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
|
||||
): Promise<{ tags: NicoTag[]
|
||||
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
|
||||
count: number }> =>
|
||||
await apiGet ('/tags/nico', { params: {
|
||||
page,
|
||||
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
|
||||
{ id, page, limit }: {
|
||||
id?: string
|
||||
page: number
|
||||
limit: number },
|
||||
): Promise<{
|
||||
limit: number }): Promise<{
|
||||
versions: TagVersion[]
|
||||
count: number }> =>
|
||||
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
|
||||
|
||||
|
||||
export const fetchDeerjikistsByTag = async (
|
||||
id: string,
|
||||
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
|
||||
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
|
||||
await apiGet (`/tags/${ id }/deerjikists`)
|
||||
|
||||
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
||||
|
||||
|
||||
export const canEditContent = (
|
||||
user: Pick<User, 'role'> | null | undefined,
|
||||
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
||||
user: Pick<User, 'role'> | null | undefined): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
||||
|
||||
@@ -12,8 +12,7 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
|
||||
|
||||
export const dateString = (
|
||||
d: string | Date,
|
||||
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null,
|
||||
): string =>
|
||||
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
|
||||
toDate (d).toLocaleString (
|
||||
'ja-JP-u-ca-japanese',
|
||||
{ era: 'long',
|
||||
@@ -28,8 +27,7 @@ export const dateString = (
|
||||
|
||||
export const originalCreatedAtString = (
|
||||
f: string | Date | null,
|
||||
b: string | Date | null,
|
||||
): string => {
|
||||
b: string | Date | null): string => {
|
||||
const from = f ? toDate (f) : null
|
||||
const before = b ? toDate (b) : null
|
||||
|
||||
|
||||
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
|
||||
|
||||
|
||||
export const fetchWikiPages = async (
|
||||
{ title }: { title?: string },
|
||||
): Promise<WikiPage[]> =>
|
||||
{ title }: { title?: string }): Promise<WikiPage[]> =>
|
||||
await apiGet ('/wiki', { params: { title } })
|
||||
|
||||
|
||||
export const fetchWikiPage = async (
|
||||
id: string,
|
||||
{ version }: { version?: string },
|
||||
): Promise<WikiPage> =>
|
||||
{ version }: { version?: string }): Promise<WikiPage> =>
|
||||
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
|
||||
|
||||
|
||||
export const fetchWikiPageByTitle = async (
|
||||
title: string,
|
||||
{ version }: { version?: string },
|
||||
): Promise<WikiPage | null> => {
|
||||
{ version }: { version?: string }): Promise<WikiPage | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
|
||||
|
||||
新しい課題から参照
ユーザをブロックする