From 64dd8ed05ec3cdbe883b306c8f79d6718a3f478d Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:29 +0300 Subject: [PATCH] feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches --- api/openapi.yaml | 25 + apps/web/e2e/upload-review.spec.ts | 114 +++ apps/web/src/features/uploads/QueueRows.tsx | 23 +- apps/web/src/features/uploads/files.ts | 19 + .../web/src/features/uploads/uploader.test.ts | 54 ++ apps/web/src/features/uploads/uploader.ts | 77 +- .../src/pages/admin/WriteTemplateSection.tsx | 48 +- apps/web/src/pages/uploads/MyUploadsPage.tsx | 119 ++- apps/web/src/pages/uploads/SuggestPage.tsx | 6 +- apps/web/src/pages/uploads/uploads.module.css | 40 + docs/uploads.md | 19 +- packages/api-client/src/generated/schema.ts | 12 + packages/i18n/locales/en/admin.json | 18 + packages/i18n/locales/en/errors.json | 2 +- packages/i18n/locales/ru/admin.json | 18 + packages/i18n/locales/ru/errors.json | 2 +- services/api/internal/api/gen/api.gen.go | 779 +++++++++--------- .../api/internal/dbtest/integration_test.go | 18 +- services/api/internal/library/handler.go | 2 +- .../internal/library/meta/template_suggest.go | 129 +++ .../library/meta/template_suggest_test.go | 22 + services/api/internal/library/templates.go | 16 +- services/api/internal/uploads/batch.go | 24 + services/api/internal/uploads/handler.go | 15 +- services/api/internal/uploads/model.go | 1 + services/api/internal/uploads/service.go | 34 +- services/api/internal/uploads/store.go | 28 +- services/api/internal/uploads/uploads_test.go | 20 + services/api/internal/uploads/worker.go | 6 +- 29 files changed, 1219 insertions(+), 471 deletions(-) create mode 100644 apps/web/e2e/upload-review.spec.ts create mode 100644 apps/web/src/features/uploads/uploader.test.ts create mode 100644 services/api/internal/library/meta/template_suggest.go create mode 100644 services/api/internal/library/meta/template_suggest_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index ecfa38e..d6cba98 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -910,6 +910,12 @@ paths: tags: [uploads] summary: Мои предложения (moderator, admin) parameters: + - name: batch + in: query + description: Только файлы указанной собственной пачки + schema: + type: string + format: uuid - $ref: "#/components/parameters/UploadStatusFilter" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" @@ -2743,6 +2749,10 @@ components: type: object required: [files] properties: + cover_base64: + type: string + maxLength: 11184812 + description: Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. comment: type: string maxLength: 1000 @@ -2914,6 +2924,10 @@ components: type: string nullable: true description: Путь в хранилище по write_template (null — не построился, причина в status_reason) + target_source_name: + type: string + nullable: true + description: Название целевого хранилища; null, если хранилище не выбрано или удалено status_reason: type: string nullable: true @@ -3614,6 +3628,17 @@ components: properties: template: type: string + matched: + type: integer + description: Число путей выборки, совпавших с предложенным шаблоном + total: + type: integer + description: Число исследованных файлов хранилища + examples: + type: array + items: + type: string + description: До трёх существующих путей, совпавших с шаблоном WriteTemplateCheckRequest: type: object required: [template] diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts new file mode 100644 index 0000000..e892c29 --- /dev/null +++ b/apps/web/e2e/upload-review.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); + await page.goto('/login'); + await page.getByLabel('Логин', { exact: true }).fill('mobile-admin'); + await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password'); + await page.getByRole('button', { name: 'Войти', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible(); + await page.evaluate(async (scenario) => { + const modulePath = '/src/test/mobileFixtures.ts'; + const { mobileFixtures } = await import(modulePath); + const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]); + const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]); + const files = [ + { ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac' }, + { ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' }, + { ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' }, + ]; + const missing = { ...file, id: 'missing', original_name: 'missing.flac', target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' }; + let failOnce = true; + const deleted: string[] = []; + const original = window.fetch; + const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); + window.fetch = async (input, init) => { + const req = new Request(input, init); + const url = new URL(req.url); + if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null }); + if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') { + const body = await req.json(); + Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null }); + return json(missing); + } + if (scenario === 'template') { + if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] }); + if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] }); + if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') { + const body = await req.json(); + document.documentElement.dataset.savedTemplate = body.write_template; + Object.assign(source, body); + return json(source); + } + if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source); + } + if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') { + if (url.searchParams.has('batch')) { + document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1); + return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' }); + } + return json({ items: files.slice(0, 2), next_cursor: 'more' }); + } + if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) { + const id = url.pathname.split('/').pop()!; + deleted.push(id); + document.documentElement.dataset.deleted = JSON.stringify(deleted); + if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); } + files.find((f) => f.id === id)!.status = 'withdrawn'; + return new Response(null, { status: 204 }); + } + return original(input, init); + }; + const target = scenario === 'queue' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`; + history.pushState({}, '', target); + window.dispatchEvent(new PopStateEvent('popstate')); + }, scenario); +} + +test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => { + await setup(page, 'queue'); + await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible(); + await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist'); + await dialog.getByLabel('Название', { exact: true }).fill('Fixed'); + await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' }); + await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click(); + await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible(); + await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); +}); + +test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => { + await setup(page, 'template'); + const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true }); + await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}'); + await expect(page.getByText(/180 из 200/)).toBeVisible(); + await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible(); + await expect(page.locator('html')).not.toHaveAttribute('data-saved-template'); + await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}'); + const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..'); + await section.getByRole('checkbox').check(); + await section.getByRole('button', { name: 'Сохранить', exact: true }).click(); + await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}'); + await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true }); +}); + +test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => { + await setup(page, 'mine'); + await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check(); + await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2'); + await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible(); + await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click(); + await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]'); + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1); + await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true }); + await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click(); + await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]'); + await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0); +}); diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx index cbc5ca1..6741102 100644 --- a/apps/web/src/features/uploads/QueueRows.tsx +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -127,8 +127,7 @@ function QueueRow({ m && {t('uploads.flag_new_album')} )} - {f.target_rel_path && {f.target_rel_path}} - {reason && {reason}} + {reason && (!editable || f.target_rel_path) && {reason}} {f.duplicate_level && f.status === 'duplicate' && ( {t(`uploads.dup_level_${f.duplicate_level}`)} )} @@ -148,6 +147,26 @@ function QueueRow({ )} + {(editable || f.target_rel_path) && ( +
+ {t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')} + {f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')} + {f.target_rel_path ? ( + <> + {f.target_rel_path} + {t('uploads.target_path_hint')} + + ) : ( + <> + {t('uploads.target_path_missing')} + {reason && {reason}} + + + )} +
+ )} ); } diff --git a/apps/web/src/features/uploads/files.ts b/apps/web/src/features/uploads/files.ts index ae6af72..dc2b2a5 100644 --- a/apps/web/src/features/uploads/files.ts +++ b/apps/web/src/features/uploads/files.ts @@ -30,6 +30,25 @@ export function isAudio(name: string): boolean { return AUDIO_EXTENSIONS.includes(ext); } +export function isAlbumCover(name: string): boolean { + return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name); +} + +export function albumFolder(relPath: string): string { + const path = relPath.replaceAll('\\', '/'); + const at = path.lastIndexOf('/'); + return at < 0 ? '' : path.slice(0, at); +} + +export function coverBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(',')[1]!); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + /** Файлы из (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */ export function fromInput(list: FileList | null): PickedFile[] { return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name })); diff --git a/apps/web/src/features/uploads/uploader.test.ts b/apps/web/src/features/uploads/uploader.test.ts new file mode 100644 index 0000000..32750c0 --- /dev/null +++ b/apps/web/src/features/uploads/uploader.test.ts @@ -0,0 +1,54 @@ +import { AuthSession } from '@pipu/core'; +import { waitFor } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { Uploader } from './uploader'; + +vi.mock('music-metadata', () => ({ + parseBlob: vi.fn(async (file: File) => ({ + format: {}, common: file.name.startsWith('01') ? { picture: [{ data: new Uint8Array([0xff, 0xd8, 0xff, 1]), format: 'image/jpeg', type: 'Cover (front)' }] } : {}, + })), +})); +vi.mock('tus-js-client', () => ({ + Upload: class { + constructor(_file: File, private options: { onSuccess: () => void }) {} + start() { queueMicrotask(this.options.onSuccess); } + }, +})); + +afterEach(() => { vi.unstubAllGlobals(); localStorage.clear(); }); + +it('keeps external and embedded album covers in separate batches without skipping images', async () => { + vi.stubGlobal('Worker', class extends EventTarget { + postMessage({ id }: { id: string }) { + queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } }))); + } + }); + const batches: { cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = []; + const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => { + const req = input as Request; + const body = await req.json(); + const data = req.url.endsWith('/precheck') + ? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) } + : (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) }); + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + } }); + const uploader = new Uploader(session); + uploader.add([ + { file: new File(['external-image'], 'Cover.jpg'), relPath: 'A/Cover.jpg' }, + { file: new File(['audio'], '02.flac'), relPath: 'A/02.flac' }, + { file: new File(['audio'], '01.flac'), relPath: 'A/01.flac' }, + { file: new File(['audio'], '02.flac'), relPath: 'B/02.flac' }, + { file: new File(['audio'], '01.flac'), relPath: 'B/01.flac' }, + ]); + await waitFor(() => expect(uploader.ready).toHaveLength(4)); + expect(uploader.getState().skipped).toBe(0); + expect(uploader.getState().items.find((f) => f.relPath === 'A/01.flac')?.coverName).toBe('Cover.jpg'); + expect(uploader.getState().items.find((f) => f.relPath === 'B/01.flac')?.coverName).toBe('embedded-cover'); + await uploader.start('Two albums'); + expect(batches).toHaveLength(2); + expect(batches[0]!.cover_base64).toBe(btoa('external-image')); + expect(batches[1]!.cover_base64).toBe(btoa(String.fromCharCode(0xff, 0xd8, 0xff, 1))); + expect(batches[0]!.files.map((f) => f.rel_path)).toEqual(['A/01.flac', 'A/02.flac']); + expect(batches[1]!.files.map((f) => f.rel_path)).toEqual(['B/01.flac', 'B/02.flac']); + expect(uploader.getState().items.every((f) => f.stage === 'done')).toBe(true); +}); diff --git a/apps/web/src/features/uploads/uploader.ts b/apps/web/src/features/uploads/uploader.ts index fc1269d..9e83bc1 100644 --- a/apps/web/src/features/uploads/uploader.ts +++ b/apps/web/src/features/uploads/uploader.ts @@ -1,7 +1,7 @@ import { unwrap, type AuthSession } from '@pipu/core'; import * as tus from 'tus-js-client'; -import { isAudio, type PickedFile } from './files'; +import { albumFolder, coverBase64, isAlbumCover, isAudio, type PickedFile } from './files'; import type { HashRequest, HashResponse } from './hash.worker'; // Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей @@ -29,6 +29,7 @@ export interface UploadItem { progress: number; // 0..1 текущего этапа (хэш или загрузка) sha256?: string; tags?: { artist?: string; title?: string; album?: string; duration_ms?: number }; + coverName?: string; trackId?: string | null; possibleDuplicate?: boolean; code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … @@ -72,6 +73,7 @@ export class Uploader { private transferWorkers = 0; private transferDone: (() => void) | null = null; private refreshing: Promise | null = null; + private folderCovers = new Map(); constructor(private readonly session: AuthSession) { this.restore(); @@ -100,17 +102,21 @@ export class Uploader { /** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */ add(files: PickedFile[]) { if (this.state.busy) return; + for (const f of files) { + if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file); + } const fresh: UploadItem[] = []; let skipped = 0; for (const f of files) { if (!isAudio(f.file.name)) { - skipped++; + if (!isAlbumCover(f.file.name)) skipped++; continue; } // Тот же путь и размер уже в списке — не добавляем второй раз. if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue; fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 }); } + fresh.sort((a, b) => a.relPath.localeCompare(b.relPath, undefined, { numeric: true })); this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null }); this.hashQueue.push(...fresh.map((it) => it.id)); void this.hashNext(); @@ -127,6 +133,7 @@ export class Uploader { clear() { if (this.state.busy) return; this.hashQueue = []; + this.folderCovers.clear(); this.set({ items: [], skipped: 0, batchId: null, error: null }); this.persist(); } @@ -220,30 +227,43 @@ export class Uploader { if (this.state.busy || !ready.length) return; this.set({ busy: true, error: null }); try { - const res = unwrap( - await this.session.client.POST('/api/v1/uploads', { - body: { - comment: comment.trim() || undefined, - files: ready.map((it) => ({ - client_id: it.id, - name: it.file?.name ?? it.relPath, - rel_path: it.relPath.includes('/') ? it.relPath : undefined, - size: it.size, - sha256: it.sha256!, - })), - }, - }), - ); - this.set({ batchId: res.batch_id }); + const groups = new Map(); + for (const it of ready) { + const folder = albumFolder(it.relPath); + groups.set(folder, [...(groups.get(folder) ?? []), it]); + } const queue: { id: string; url: string }[] = []; - for (const f of res.files) { - if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id }); - else if (f.tus_url && f.upload_file_id) { - this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 }); - queue.push({ id: f.client_id, url: f.tus_url }); + for (const [folder, group] of groups) { + try { + const cover = this.folderCovers.get(folder); + const res = unwrap( + await this.session.client.POST('/api/v1/uploads', { + body: { + comment: comment.trim() || undefined, + ...(cover ? { cover_base64: await coverBase64(cover) } : {}), + files: group.map((it) => ({ + client_id: it.id, + name: it.file?.name ?? it.relPath, + rel_path: it.relPath.includes('/') ? it.relPath : undefined, + size: it.size, + sha256: it.sha256!, + })), + }, + }), + ); + this.set({ batchId: res.batch_id }); + for (const f of res.files) { + if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id }); + else if (f.tus_url && f.upload_file_id) { + this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 }); + queue.push({ id: f.client_id, url: f.tus_url }); + } + } + this.persist(); + } catch (err) { + this.set({ error: err }); } } - this.persist(); await this.transfer(queue); } catch (err) { this.set({ error: err }); @@ -352,7 +372,12 @@ export class Uploader { if (!it.file) return; try { const { parseBlob } = await import('music-metadata'); - const meta = await parseBlob(it.file); + const folder = albumFolder(it.relPath); + const meta = await parseBlob(it.file, { skipCovers: this.folderCovers.has(folder) }); + if (!this.folderCovers.has(folder)) { + const picture = meta.common.picture?.find((p) => p.type === 'Cover (front)') ?? meta.common.picture?.[0]; + if (picture) this.folderCovers.set(folder, new File([new Uint8Array(picture.data)], 'embedded-cover', { type: picture.format })); + } const duration = meta.format.duration; this.patch(it.id, { tags: { @@ -365,6 +390,10 @@ export class Uploader { } catch { // Теги — подсказка для предпроверки; без них сервер прочитает их сам. } + const folder = albumFolder(it.relPath); + const cover = this.folderCovers.get(folder); + const first = this.state.items.find((item) => albumFolder(item.relPath) === folder); + if (cover && first) this.patch(first.id, { coverName: cover.name }); } /** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */ diff --git a/apps/web/src/pages/admin/WriteTemplateSection.tsx b/apps/web/src/pages/admin/WriteTemplateSection.tsx index 7273a62..b5c5670 100644 --- a/apps/web/src/pages/admin/WriteTemplateSection.tsx +++ b/apps/web/src/pages/admin/WriteTemplateSection.tsx @@ -1,7 +1,7 @@ import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; @@ -27,30 +27,40 @@ export function WriteTemplateSection({ source }: { source: Source }) { const [accepts, setAccepts] = useState(source.accepts_uploads); const [template, setTemplate] = useState(source.write_template ?? ''); const [check, setCheck] = useState(null); + const revision = useRef(0); useEffect(() => { setAccepts(source.accepts_uploads); setTemplate(source.write_template ?? ''); }, [source.accepts_uploads, source.write_template]); const runCheck = useMutation({ - mutationFn: async (tpl: string) => - unwrap( + mutationFn: async (tpl: string) => { + const requestedRevision = revision.current; + const result = unwrap( await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', { params: { path: { id } }, body: { template: tpl }, }), - ), - onSuccess: setCheck, - }); - const suggest = useMutation({ - mutationFn: async () => - unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } })) - .template, - onSuccess: (tpl) => { - setTemplate(tpl); - runCheck.mutate(tpl); + ); + return { result, requestedRevision }; + }, + onSuccess: ({ result, requestedRevision }) => { + if (requestedRevision === revision.current) setCheck(result); }, }); + const suggest = useMutation({ + mutationFn: async (_revision: number) => + unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } })), + onSuccess: (result, requestedRevision) => { + if (requestedRevision !== revision.current) return; + setTemplate(result.template); + runCheck.mutate(result.template); + }, + }); + const suggestTemplate = suggest.mutate; + useEffect(() => { + if (!source.write_template) suggestTemplate(revision.current); + }, [id, source.write_template, suggestTemplate]); const save = useMutation({ mutationFn: async () => unwrap( @@ -73,6 +83,15 @@ export function WriteTemplateSection({ source }: { source: Source }) {

{t('write_template.title')}

{t('write_template.intro')}

+

{t('write_template.auto_hint')}

+ {suggest.isPending &&

{t('write_template.detecting')}

} + {suggest.data && ( +
+

{t('write_template.detected', { matched: suggest.data.matched ?? 0, total: suggest.data.total ?? 0 })}

+ {suggest.data.template} + {(suggest.data.examples ?? []).map((path) => {path})} +
+ )}