Enhance manual inbox dialog: add regex presets and hints for episode number extraction, improve user interface for file selection, and update translations for better user guidance. Refactor state management and query handling to streamline the import process and enhance overall user experience in manual media management.
This commit is contained in:
@@ -21,6 +21,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { importManualInbox, listManualInbox } from './api'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||
|
||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -101,6 +102,24 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
}, [data, query, parsedByPath])
|
||||
|
||||
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
||||
|
||||
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
||||
const sample = selectable[0] ?? folders[0]?.files[0]
|
||||
const sampleParts = useMemo(() => {
|
||||
if (!sample) return []
|
||||
const numbers = findNumbers(sample.name)
|
||||
const parts: { text: string; number: number | null }[] = []
|
||||
let cursor = 0
|
||||
for (const number of numbers) {
|
||||
if (number.start > cursor)
|
||||
parts.push({ text: sample.name.slice(cursor, number.start), number: null })
|
||||
parts.push({ text: number.text, number: number.index })
|
||||
cursor = number.start + number.text.length
|
||||
}
|
||||
if (cursor < sample.name.length)
|
||||
parts.push({ text: sample.name.slice(cursor), number: null })
|
||||
return parts
|
||||
}, [sample])
|
||||
const recognized = selectable.filter(
|
||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||
).length
|
||||
@@ -183,12 +202,59 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.media.toShowHint')}
|
||||
{!regexOk && (
|
||||
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
||||
)}
|
||||
</p>
|
||||
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||
|
||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||
{sample && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPickHint')}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part, index) =>
|
||||
part.number === null ? (
|
||||
<span key={index} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPresets')}
|
||||
</span>
|
||||
{REGEX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr(preset.pattern)}
|
||||
>
|
||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||
</button>
|
||||
))}
|
||||
{regexStr && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr('')}
|
||||
>
|
||||
{t('admin.media.regexClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */
|
||||
export const REGEX_PRESETS: { key: string; pattern: string }[] = [
|
||||
{ key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' },
|
||||
{ key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' },
|
||||
{ key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' },
|
||||
{ key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' },
|
||||
{ key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' },
|
||||
]
|
||||
|
||||
/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */
|
||||
export function findNumbers(fileName: string): { index: number; start: number; text: string }[] {
|
||||
return [...fileName.matchAll(/\d+/g)].map((match, index) => ({
|
||||
index,
|
||||
start: match.index ?? 0,
|
||||
text: match[0],
|
||||
}))
|
||||
}
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом
|
||||
* («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет.
|
||||
* Если перед числом только разделители — якорем становятся они, а число в начале имени крепится к `^`.
|
||||
*/
|
||||
export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): string {
|
||||
const numbers = findNumbers(fileName)
|
||||
const target = numbers[occurrenceIndex]
|
||||
if (!target) return ''
|
||||
|
||||
// Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу,
|
||||
// а применяется ко всей папке, где рядом может лежать и «Серия 100».
|
||||
const digits = '(\\d{1,3})'
|
||||
const before = fileName.slice(0, target.start)
|
||||
if (!before.trim()) return `^\\s*${digits}`
|
||||
|
||||
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
|
||||
// там встречается то пробел, то точка, то подчёркивание.
|
||||
const gap = /[^\p{L}\p{N}]*$/u.exec(before)?.[0] ?? ''
|
||||
const anchorSource = before.slice(0, before.length - gap.length)
|
||||
// Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое
|
||||
// к первому сезону, и на «S02E05» оно бы уже не сработало.
|
||||
const anchor = /\p{L}+$/u.exec(anchorSource)?.[0]
|
||||
|
||||
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}`
|
||||
|
||||
// Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»).
|
||||
const punctuation = gap.trim().slice(-1)
|
||||
return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}`
|
||||
}
|
||||
@@ -208,6 +208,17 @@ const resources = {
|
||||
manualSelectAll: 'Выбрать все',
|
||||
manualSelected: 'Выбрано: {{count}}',
|
||||
manualEmpty: 'В папке manual пусто',
|
||||
regexPickHint: 'Кликните число в имени файла — по нему соберётся правило для всех файлов:',
|
||||
regexPickTitle: 'Это номер серии',
|
||||
regexPresets: 'Готовые:',
|
||||
regexPresetNames: {
|
||||
seriesWord: 'Серия N',
|
||||
episodeWord: 'Эпизод N',
|
||||
seasonEpisode: 'SxxEyy',
|
||||
afterDash: 'после тире',
|
||||
firstNumber: 'первое число',
|
||||
},
|
||||
regexClear: 'сбросить',
|
||||
manualAlready: 'уже в библиотеке',
|
||||
manualRoot: 'корень manual/',
|
||||
manualRecognized: 'Распознано: {{count}} из {{total}}',
|
||||
@@ -887,6 +898,17 @@ const resources = {
|
||||
manualSelectAll: 'Select all',
|
||||
manualSelected: 'Selected: {{count}}',
|
||||
manualEmpty: 'The manual folder is empty',
|
||||
regexPickHint: 'Click a number in the file name — a rule for all files is built from it:',
|
||||
regexPickTitle: 'This is the episode number',
|
||||
regexPresets: 'Ready-made:',
|
||||
regexPresetNames: {
|
||||
seriesWord: 'Серия N',
|
||||
episodeWord: 'Эпизод N',
|
||||
seasonEpisode: 'SxxEyy',
|
||||
afterDash: 'after a dash',
|
||||
firstNumber: 'first number',
|
||||
},
|
||||
regexClear: 'clear',
|
||||
manualAlready: 'already in the library',
|
||||
manualRoot: 'manual/ root',
|
||||
manualRecognized: 'Recognized: {{count}} of {{total}}',
|
||||
|
||||
Reference in New Issue
Block a user