diff --git a/src/i18n.ts b/src/i18n.ts index a12145a..f8baf5c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -100,6 +100,10 @@ const en = { 'brain.metricAction': 'Needs action', 'brain.metricActionSub': 'waiting for your approval', 'brain.listTitle': 'Material', + 'brain.allStatuses': 'All statuses', + 'brain.duplicateConfirm': 'Already in the Brain: {names}. Upload another copy anyway? Cancel skips these files.', + 'brain.duplicateSkipped': 'Skipped: already in the Brain', + 'brain.downloaded': '{name} is downloading: this format opens in its own app.', 'brain.loading': 'Loading material…', 'brain.engineDown': 'Cannot reach the engine', 'brain.empty': 'No material yet', @@ -622,6 +626,10 @@ const id: Record = { 'brain.metricAction': 'Perlu tindakan', 'brain.metricActionSub': 'menunggu persetujuan Anda', 'brain.listTitle': 'Materi', + 'brain.allStatuses': 'Semua status', + 'brain.duplicateConfirm': 'Sudah ada di Otak: {names}. Tetap unggah salinan lain? Batal akan melewati file ini.', + 'brain.duplicateSkipped': 'Dilewati: sudah ada di Otak', + 'brain.downloaded': '{name} sedang diunduh: format ini dibuka di aplikasinya sendiri.', 'brain.loading': 'Memuat materi…', 'brain.engineDown': 'Engine tidak dapat dihubungi', 'brain.empty': 'Belum ada materi', diff --git a/src/pages/Brain.test.ts b/src/pages/Brain.test.ts new file mode 100644 index 0000000..1d145f9 --- /dev/null +++ b/src/pages/Brain.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { duplicateNames } from './Brain' + +describe('duplicateNames', () => { + it('finds files already in the Brain, ignoring case, and repeats within one pick', () => { + const dupes = duplicateNames(['Katalog.pdf', 'new.docx', 'new.docx', 'other.pdf'], ['katalog.PDF']) + expect([...dupes]).toEqual(['Katalog.pdf', 'new.docx']) + }) +}) diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index e25a725..05842e6 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -31,19 +31,49 @@ export const useDocuments = () => useQuery({ refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000), }) +const PHASES: Phase[] = ['ready', 'working', 'answer', 'failed'] + +/** Files whose name is already in the Brain (or twice in the same pick), compared without case. */ +export function duplicateNames(names: string[], existing: string[]): Set { + const seen = new Set(existing.map(n => n.toLowerCase())) + const dupes = new Set() + for (const name of names) { + if (seen.has(name.toLowerCase())) dupes.add(name) + seen.add(name.toLowerCase()) + } + return dupes +} + export function BrainPage() { const docs = useDocuments() + const [query, setQuery] = useState('') + const [phase, setPhase] = useState('all') + const needle = query.trim().toLowerCase() + const shown = docs.data?.filter(d => (phase === 'all' || phaseOf(d) === phase) + && (!needle || `${d.title} ${d.originalName}`.toLowerCase().includes(needle))) return ( <>
-

{t('brain.listTitle')}

+
+

{t('brain.listTitle')} {docs.data && {shown?.length}}

+
+
+ setQuery(e.target.value)} /> +
+ +
+
{docs.isPending && } {docs.isError && {docs.error.message}} {docs.data?.length === 0 && {t('brain.emptyCopy')}} - {!!docs.data?.length &&
{docs.data.map(d => )}
} + {!!docs.data?.length && shown?.length === 0 && {t('res.noMatchCopy')}} + {!!shown?.length &&
{shown.map(d => )}
}
) @@ -137,9 +167,15 @@ function UploadBox() { async function upload(files: File[]) { if (!files.length) return const allowed = exts.data + // The same file uploaded again is a second copy in the Brain; it is asked about, not assumed. + const existing = queryClient.getQueryData(['documents'])?.map(d => d.originalName) ?? [] + const dupes = duplicateNames(files.map(f => f.name), existing) + const skipDupes = dupes.size > 0 && !confirm(t('brain.duplicateConfirm', { names: [...dupes].join(', ') })) const batch: UploadItem[] = files.map(f => (allowed && !allowed.has(extOf(f.name)) ? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.unsupported') } - : { id: nextUploadId++, name: f.name, state: 'uploading' })) + : skipDupes && dupes.has(f.name) + ? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.duplicateSkipped') } + : { id: nextUploadId++, name: f.name, state: 'uploading' })) // Appended, so files dropped while an earlier batch is still uploading keep their own rows. setItems(prev => [...prev.filter(it => it.state === 'uploading'), ...batch]) // One at a time: each upload enqueues a processing job, and the list refreshes after each. @@ -248,8 +284,11 @@ function OpenButton({ doc }: { doc: Source }) { try { const url = URL.createObjectURL(await documentFile(doc.docId)) if (tab) tab.location.href = url - // Not viewable, or the tab was blocked: save it instead of doing nothing. - else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click() + // Not viewable, or the tab was blocked: save it instead of doing nothing, and say so. + else { + Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click() + toast(t('brain.downloaded', { name: doc.originalName })) + } setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch (e) { tab?.close() @@ -265,6 +304,10 @@ function OpenButton({ doc }: { doc: Source }) { ) } +const statusName = (phase: Phase) => ({ + ready: t('brain.statusReady'), working: t('brain.statusProcessing'), answer: t('brain.statusAnswer'), failed: t('brain.statusFailed'), +}[phase]) + function statusLabel(doc: Source, phase: Phase) { return { ready: t('brain.statusReady'),