diff --git a/README.md b/README.md index 81089a3..0f1bf39 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,22 @@ Multi Hoster Uploader is a Windows desktop application for sending file batches Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.20. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.21. Use the release page for the executables and the full English changelog. ## Features ### Upload workspace - Add individual files, complete folders, or files by drag and drop. +- Filter new imports by file name with reusable include or exclude conditions before upload jobs are created. +- Review how many selected files were accepted or excluded before choosing upload destinations. - Build one job per selected file and destination. - Upload to several supported hosts from the same queue. - Filter the workspace by all, active, queued, completed, or failed jobs. - Search and filter queue entries by file name, host, and status. - Open per-upload diagnostics with the selected account, retry count, and safe error details. - Track status, smoothly interpolated progress, transferred size, speed, and the selected host account. -- Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry. +- Read remaining, total, running, connection, completed, and failed upload activity from the persistent sidebar telemetry. - Follow current upload speed in the sidebar and the synchronized header graph. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work. - Copy completed links individually or together. diff --git a/lib/config-store.js b/lib/config-store.js index c9c872f..901ff97 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -78,6 +78,12 @@ const DEFAULTS = { lastBrowseDirectory: '', removeFromQueueOnDone: false, deleteSourceAfterSuccessfulUpload: false, + filenameFilter: { + enabled: false, + action: 'include', + matchMode: 'all', + conditions: [] + }, showDropTarget: false, globalMaxSpeedKbs: 0, // 0 = unlimited global speed pendingQueue: null, diff --git a/lib/filename-filter.js b/lib/filename-filter.js new file mode 100644 index 0000000..765e6c4 --- /dev/null +++ b/lib/filename-filter.js @@ -0,0 +1,70 @@ +(function initFilenameFilter(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.FilenameFilter = api; +})(typeof window !== 'undefined' ? window : globalThis, function createFilenameFilter() { + function normalizeFilenameFilter(value) { + const source = value && typeof value === 'object' ? value : {}; + const conditions = Array.isArray(source.conditions) + ? source.conditions.flatMap(condition => { + if (!condition || typeof condition !== 'object') return []; + const text = String(condition.value ?? '').trim(); + if (!text) return []; + return [{ + operator: condition.operator === 'notContains' ? 'notContains' : 'contains', + value: text + }]; + }) + : []; + return { + enabled: source.enabled === true, + action: source.action === 'exclude' ? 'exclude' : 'include', + matchMode: source.matchMode === 'any' ? 'any' : 'all', + conditions + }; + } + + function getFilename(entry) { + if (entry && typeof entry === 'object' && entry.name) return String(entry.name); + const source = entry && typeof entry === 'object' ? entry.path : entry; + return String(source ?? '').split(/[\\/]/).pop() || ''; + } + + function evaluateFilenameFilter(filename, value) { + const filter = normalizeFilenameFilter(value); + const active = filter.enabled && filter.conditions.length > 0; + if (!active) return { accepted: true, matched: false, active, filter }; + const normalizedName = String(filename ?? '').toLowerCase(); + const results = filter.conditions.map(condition => { + const contains = normalizedName.includes(condition.value.toLowerCase()); + return condition.operator === 'notContains' ? !contains : contains; + }); + const matched = filter.matchMode === 'any' ? results.some(Boolean) : results.every(Boolean); + const accepted = filter.action === 'exclude' ? !matched : matched; + return { accepted, matched, active, filter }; + } + + function applyFilenameFilter(entries, value) { + const filter = normalizeFilenameFilter(value); + const accepted = []; + const excluded = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const evaluation = evaluateFilenameFilter(getFilename(entry), filter); + if (evaluation.accepted) accepted.push(entry); + else excluded.push(entry); + } + return { + total: accepted.length + excluded.length, + accepted, + excluded, + active: filter.enabled && filter.conditions.length > 0, + filter + }; + } + + return { + normalizeFilenameFilter, + evaluateFilenameFilter, + applyFilenameFilter + }; +}); diff --git a/package-lock.json b/package-lock.json index 3853433..ec9d9f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.20", + "version": "2.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.20", + "version": "2.1.21", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 76c140c..6484f7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.20", + "version": "2.1.21", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 06b9469..991b8dd 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -651,12 +651,14 @@ async function init() { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } - if (newFiles.length > 0) { - const newPaths = new Set(newFiles.map(f => f.path)); + const admitted = admitFilenameFilter(newFiles); + if (admitted.accepted.length > 0) { + const newPaths = new Set(admitted.accepted.map(f => f.path)); clearDedupKeysForPaths(newPaths); - selectedFiles.push(...newFiles); + selectedFiles.push(...admitted.accepted); buildQueuePreview(); updateUploadView(); + if (admitted.active && admitted.excluded.length > 0) showFilenameFilterResult(admitted); if (fm.autoStart && !uploading && !healthCheckRunning) { startUpload(); } else if (uploading) { @@ -666,6 +668,8 @@ async function init() { await startSelectedUpload(newJobs); } } + } else if (admitted.active && admitted.total > 0) { + showFilenameFilterResult(admitted); } } else { // No pre-selected hosters: open modal @@ -1260,6 +1264,12 @@ function renderHosterModal() { function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); + const description = document.getElementById('hosterModalDescription'); + if (description) { + description.textContent = _pendingImportSummary + ? formatFilenameFilterResult(_pendingImportSummary) + : localizeUiText('Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.'); + } modalController.open('hosterModal', { initialFocus: '#cancelHosterModalBtn', fallbackFocus: '#addFilesBtn', @@ -1298,10 +1308,12 @@ async function applyHosterSelection() { updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files closeHosterModal(); + _pendingImportSummary = null; } function cancelHosterModal() { _pendingFiles = []; + _pendingImportSummary = null; closeHosterModal(); } @@ -1538,9 +1550,37 @@ function setupDragDrop() { } let _pendingFiles = []; // Files waiting for hoster modal confirmation +let _pendingImportSummary = null; let _addingDropped = false; +function admitFilenameFilter(files) { + return window.FilenameFilter.applyFilenameFilter(files, config?.globalSettings?.filenameFilter); +} + +function mergePendingImportSummary(result) { + if (!result.active) { + if (!_pendingImportSummary) _pendingImportSummary = null; + return; + } + const current = _pendingImportSummary || { total: 0, accepted: 0, excluded: 0 }; + _pendingImportSummary = { + total: current.total + result.total, + accepted: current.accepted + result.accepted.length, + excluded: current.excluded + result.excluded.length + }; +} + +function formatFilenameFilterResult(result) { + const accepted = Array.isArray(result.accepted) ? result.accepted.length : Number(result.accepted) || 0; + const excluded = Array.isArray(result.excluded) ? result.excluded.length : Number(result.excluded) || 0; + return localizeUiText(`${accepted} von ${result.total} Dateien werden hinzugefügt. ${excluded} durch den Dateinamenfilter ausgeschlossen.`); +} + +function showFilenameFilterResult(result) { + showCopyToast(formatFilenameFilterResult(result), 6500); +} + async function addDropTargetEntries(entries) { const files = []; for (const entry of Array.isArray(entries) ? entries : []) { @@ -1563,12 +1603,7 @@ async function addDroppedFiles(fileList) { _addingDropped = true; try { const files = Array.from(fileList); - const existingPaths = new Set([ - ...selectedFiles.map(f => f.path), - ..._pendingFiles.map(f => f.path) - ]); - const newFiles = []; - let duplicateCount = 0; + const entries = []; for (const file of files) { let filePath = ''; @@ -1583,14 +1618,9 @@ async function addDroppedFiles(fileList) { for (const fp of folderFiles) { const p = typeof fp === 'string' ? fp : (fp && fp.path); if (!p) continue; - if (existingPaths.has(p)) { - duplicateCount++; - continue; - } const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop()); const size = typeof fp === 'string' ? null : (fp.size || 0); - newFiles.push({ path: p, name, size }); - existingPaths.add(p); + entries.push({ path: p, name, size }); } continue; } @@ -1599,20 +1629,9 @@ async function addDroppedFiles(fileList) { // Regular file const fileName = file.name || ''; - if (!existingPaths.has(filePath)) { - newFiles.push({ path: filePath, name: fileName, size: file.size }); - existingPaths.add(filePath); - } else { - duplicateCount++; - } - } - - if (newFiles.length > 0) { - _pendingFiles.push(...newFiles); - openHosterModal(); - } else if (duplicateCount > 0) { - showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.'); + entries.push({ path: filePath, name: fileName, size: file.size }); } + addPathsToQueue(entries); } finally { _addingDropped = false; } @@ -1648,11 +1667,15 @@ function addPathsToQueue(paths) { newFiles.push({ path: p, name, size }); if (size === null || size === undefined || size === 0) pendingSizeFetch.push(p); } - if (newFiles.length > 0) { - _pendingFiles.push(...newFiles); + const admitted = admitFilenameFilter(newFiles); + if (admitted.accepted.length > 0) { + const acceptedPaths = new Set(admitted.accepted.map(file => file.path)); + const acceptedSizeFetch = pendingSizeFetch.filter(filePath => acceptedPaths.has(filePath)); + _pendingFiles.push(...admitted.accepted); + mergePendingImportSummary(admitted); openHosterModal(); - if (pendingSizeFetch.length > 0 && window.api.getFileSizes) { - window.api.getFileSizes(pendingSizeFetch).then((sizeMap) => { + if (acceptedSizeFetch.length > 0 && window.api.getFileSizes) { + window.api.getFileSizes(acceptedSizeFetch).then((sizeMap) => { if (!sizeMap || typeof sizeMap !== 'object') return; let changed = false; for (const f of _pendingFiles) { @@ -1671,6 +1694,10 @@ function addPathsToQueue(paths) { } }).catch(() => {}); } + } else if (admitted.active && admitted.total > 0) { + showFilenameFilterResult(admitted); + } else if (Array.isArray(paths) && paths.length > 0) { + showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.'); } } @@ -4605,6 +4632,79 @@ async function _renderLogPathsList(el) { } } +function filenameFilterConditionRowHtml(condition = {}) { + const operator = condition.operator === 'notContains' ? 'notContains' : 'contains'; + return ` +
+
+ Dateiname filtern + +
+
+ Vergleich + +
+ +
`; +} + +function readFilenameFilterSettings() { + const conditions = Array.from(document.querySelectorAll('[data-filename-filter-condition]')).map(row => ({ + operator: row.querySelector('[data-filename-filter-operator]')?.value || 'contains', + value: row.querySelector('[data-filename-filter-value]')?.value || '' + })); + return window.FilenameFilter.normalizeFilenameFilter({ + enabled: document.getElementById('filenameFilterEnabledInput')?.checked === true, + action: document.getElementById('filenameFilterActionInput')?.value, + matchMode: document.getElementById('filenameFilterMatchModeInput')?.value, + conditions + }); +} + +function syncFilenameFilterControls() { + const enabled = document.getElementById('filenameFilterEnabledInput')?.checked === true; + const panel = document.getElementById('filenameFilterBuilder'); + if (panel) panel.classList.toggle('disabled', !enabled); + const rows = Array.from(document.querySelectorAll('[data-filename-filter-condition]')); + document.querySelectorAll('#filenameFilterBuilder select, #filenameFilterBuilder input, #addFilenameFilterConditionBtn').forEach(control => { + control.disabled = !enabled; + }); + rows.forEach(row => { + const remove = row.querySelector('[data-filename-filter-remove]'); + if (remove) remove.disabled = !enabled || rows.length === 1; + }); +} + +function wireFilenameFilterConditionRow(row) { + if (!row || row.dataset.wired === 'true') return; + row.dataset.wired = 'true'; + row.querySelectorAll('.settings-autosave').forEach(control => { + const eventName = control.tagName === 'SELECT' ? 'change' : 'input'; + control.addEventListener(eventName, markSettingsDirty); + }); + row.querySelector('[data-filename-filter-remove]')?.addEventListener('click', () => { + row.remove(); + syncFilenameFilterControls(); + markSettingsDirty(); + }); +} + +function appendFilenameFilterCondition(condition = {}) { + const list = document.getElementById('filenameFilterConditions'); + if (!list) return; + const template = document.createElement('template'); + template.innerHTML = filenameFilterConditionRowHtml(condition).trim(); + const row = template.content.firstElementChild; + list.appendChild(row); + wireFilenameFilterConditionRow(row); + syncFilenameFilterControls(); + row.querySelector('[data-filename-filter-value]')?.focus(); + markSettingsDirty(); +} + function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; @@ -4613,10 +4713,14 @@ function renderSettings() { const configuredAccounts = getAvailableHosters(); const fm = globalSettings.folderMonitor || {}; const remoteSettings = globalSettings.remote || {}; + const filenameFilter = window.FilenameFilter.normalizeFilenameFilter(globalSettings.filenameFilter); + const filenameFilterConditions = filenameFilter.conditions.length > 0 + ? filenameFilter.conditions + : [{ operator: 'contains', value: '' }]; const pageDefinitions = [ { id: 'allgemein', label: 'Allgemein', search: 'fenster window vordergrund foreground always on top drop target oberfläche interface updates update aktualisierung version language sprache' }, - { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster' }, + { id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster dateiname filename filter enthält contains ausschließen exclude' }, { id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' }, { id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'benachrichtigungen notifications webhook discord meldung message ping erwähnung mention batch fertig completed' }, { id: 'logs', label: 'Logs & Support', search: 'log logs protokoll logging debug verbose diagnose diagnostics support paket package datei file ordner folder' }, @@ -4748,6 +4852,41 @@ function renderSettings() { +
Dateinamenfilter
+
+
+
+ + Prüft neue Dateien aus Auswahl, Ordnern, Drag-and-drop und Ordnerüberwachung, bevor sie in die Upload-Liste gelangen. +
+ +
+
+
+ ${filenameFilterConditions.map(filenameFilterConditionRowHtml).join('')} +
+ +
+
+ + +
+
+ + +
+
+
+
Quelldateien
@@ -5247,6 +5386,10 @@ function renderSettings() { document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); + container.querySelectorAll('[data-filename-filter-condition]').forEach(wireFilenameFilterConditionRow); + document.getElementById('addFilenameFilterConditionBtn')?.addEventListener('click', () => appendFilenameFilterCondition()); + document.getElementById('filenameFilterEnabledInput')?.addEventListener('change', syncFilenameFilterControls); + syncFilenameFilterControls(); _syncHeaderUpdateState(); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; @@ -5393,6 +5536,7 @@ async function performSaveSettings(options = {}) { scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads), removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone), deleteSourceAfterSuccessfulUpload: elChk('deleteSourceAfterSuccessfulUploadInput', !!cur.deleteSourceAfterSuccessfulUpload), + filenameFilter: readFilenameFilterSettings(), showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget), globalMaxSpeedKbs: (() => { const el = document.getElementById('globalMaxSpeedMbsInput'); diff --git a/renderer/i18n.js b/renderer/i18n.js index 96204eb..789d07a 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -37,6 +37,27 @@ ['Fensterverhalten, Drop-Target und Programmupdates.', 'Window behavior, drop target, and application updates.'], ['Gesamt:', 'Total:'], ['Globales Speed-Limit', 'Global speed limit'], + ['Dateinamenfilter', 'Filename filter'], + ['Dateinamen beim Hinzufügen filtern', 'Filter filenames when adding files'], + ['Prüft neue Dateien aus Auswahl, Ordnern, Drag-and-drop und Ordnerüberwachung, bevor sie in die Upload-Liste gelangen.', 'Checks new files from selections, folders, drag and drop, and folder monitoring before they enter the upload list.'], + ['Treffer', 'Matches'], + ['Wenn passend', 'When matched'], + ['Bedingungen', 'Conditions'], + ['nur hinzufügen', 'add only'], + ['nicht hinzufügen', 'do not add'], + ['Dateien hinzufügen', 'Add files'], + ['Dateien nicht hinzufügen', 'Do not add files'], + ['alle müssen passen', 'all must match'], + ['mindestens eine muss passen', 'at least one must match'], + ['Dateinamen-Bedingung', 'Filename condition'], + ['Vergleich', 'Comparison'], + ['enthält', 'contains'], + ['enthält nicht', 'does not contain'], + ['Dateiname filtern', 'Filter file name'], + ['z. B. 720p', 'e.g. 720p'], + ['Bedingung entfernen', 'Remove condition'], + ['+ Bedingung', '+ Condition'], + ['Groß- und Kleinschreibung werden ignoriert. Leere Bedingungen werden nicht gespeichert.', 'Matching ignores letter case. Empty conditions are not saved.'], ['Hauptbereiche', 'Main sections'], ['Hinweis', 'Notice'], ['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'], @@ -663,6 +684,7 @@ [/^(\d+) hinzugefügt$/, '$1 added'], [/^(\d+) bereits im Batch$/, '$1 already in the batch'], [/^(\d+) ohne gültigen Account$/, '$1 without a valid account'], + [/^(\d+) von (\d+) Dateien werden hinzugefügt\. (\d+) durch den Dateinamenfilter ausgeschlossen\.$/, '$1 of $2 files will be added. $3 excluded by the filename filter.'], [/^Sitzungsbericht mit 1 Upload exportiert$/, 'Session report with 1 upload exported'], [/^Sitzungsbericht mit (\d+) Uploads exportiert$/, 'Session report with $1 uploads exported'], [/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'], @@ -782,6 +804,7 @@ [/^(\d+) added$/, '$1 hinzugefügt'], [/^(\d+) already in the batch$/, '$1 bereits im Batch'], [/^(\d+) without a valid account$/, '$1 ohne gültigen Account'], + [/^(\d+) of (\d+) files will be added\. (\d+) excluded by the filename filter\.$/, '$1 von $2 Dateien werden hinzugefügt. $3 durch den Dateinamenfilter ausgeschlossen.'], [/^Session report with 1 upload exported$/, 'Sitzungsbericht mit 1 Upload exportiert'], [/^Session report with (\d+) uploads exported$/, 'Sitzungsbericht mit $1 Uploads exportiert'], [/^Sleep in (\d+)s\.\.\.$/, 'Ruhezustand in $1s...'], diff --git a/renderer/index.html b/renderer/index.html index 94b8388..e5c87a0 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -209,10 +209,10 @@
Keine Upload-Ziele ausgewählt
-
Gesamt0
-
Verbindungen0
Verbleibend0
+
Gesamt0
Läuft0
+
Verbindungen0
Fertig0
Fehler0
Geschwindigkeit0 B/s
@@ -683,6 +683,7 @@ + diff --git a/renderer/styles.css b/renderer/styles.css index 410b2e3..b413c57 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -3303,6 +3303,103 @@ input[type="checkbox"] { border-color: var(--danger); } +.filename-filter-panel { + display: grid; + gap: 10px; +} + +.filename-filter-builder { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + transition: opacity 160ms ease, border-color 160ms ease; +} + +.filename-filter-builder.disabled { + opacity: 0.55; +} + +.filename-filter-policy { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.filename-filter-field { + display: grid; + gap: 6px; +} + +.filename-filter-field label { + color: var(--text-muted); + font-size: 13px; + font-weight: 600; +} + +.filename-filter-policy .hs-input, +.filename-filter-condition .hs-input, +.filename-filter-condition .key-input { + min-width: 0; + max-width: none; + width: 100%; +} + +.filename-filter-conditions { + display: grid; + gap: 8px; +} + +.filename-filter-condition { + display: grid; + grid-template-columns: minmax(240px, 1.35fr) minmax(150px, 0.65fr) auto; + gap: 8px; + align-items: end; +} + +.filename-filter-rule-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.filename-filter-rule-label { + color: var(--text-muted); + font-size: 13px; + font-weight: 600; +} + +.filename-filter-footer { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.filename-filter-footer .hint { + margin-left: auto; + text-align: right; +} + +@media (max-width: 820px) { + .filename-filter-policy, + .filename-filter-condition { + grid-template-columns: 1fr; + } + + .filename-filter-footer { + align-items: stretch; + flex-direction: column; + } + + .filename-filter-footer .hint { + margin-left: 0; + text-align: left; + } +} + .settings-option-description, .hint { color: var(--text-dim); diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index d998cdc..ce906ba 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -32,6 +32,7 @@ const sourceFiles = [ 'lib/doodstream-upload.js', 'lib/file-probe.js', 'lib/file-discovery.js', + 'lib/filename-filter.js', 'lib/folder-monitor.js', 'lib/hosters.js', 'lib/hoster-transport-error.js', @@ -114,6 +115,7 @@ const sourceFiles = [ 'tests/doodstream-upload.test.js', 'tests/file-probe.test.js', 'tests/file-discovery.test.js', + 'tests/filename-filter.test.js', 'tests/folder-monitor.test.js', 'tests/history-status.test.js', 'tests/history-retention.test.js', diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 0e3fece..9f19f1a 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -120,6 +120,12 @@ describe('ConfigStore', () => { assert.equal(config.globalSettings.scaleParallelUploads, false); assert.equal(config.globalSettings.lastBrowseDirectory, ''); assert.equal(config.globalSettings.pendingQueue, null); + assert.deepEqual(config.globalSettings.filenameFilter, { + enabled: false, + action: 'include', + matchMode: 'all', + conditions: [] + }); assert.deepEqual(config.history, []); }); diff --git a/tests/filename-filter.test.js b/tests/filename-filter.test.js new file mode 100644 index 0000000..cfbd3b3 --- /dev/null +++ b/tests/filename-filter.test.js @@ -0,0 +1,91 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + normalizeFilenameFilter, + evaluateFilenameFilter, + applyFilenameFilter +} = require('../lib/filename-filter'); + +describe('filename filter', () => { + it('accepts every file when the filter is disabled or has no usable conditions', () => { + const disabled = applyFilenameFilter(['Episode.1080p.mkv'], { + enabled: false, + action: 'exclude', + conditions: [{ operator: 'contains', value: '1080p' }] + }); + const empty = applyFilenameFilter(['Episode.1080p.mkv'], { + enabled: true, + action: 'include', + conditions: [{ operator: 'contains', value: ' ' }] + }); + + assert.deepEqual(disabled.accepted, ['Episode.1080p.mkv']); + assert.deepEqual(disabled.excluded, []); + assert.equal(disabled.active, false); + assert.deepEqual(empty.accepted, ['Episode.1080p.mkv']); + assert.equal(empty.active, false); + }); + + it('includes only filenames that satisfy every condition without case sensitivity', () => { + const filter = { + enabled: true, + action: 'include', + matchMode: 'all', + conditions: [ + { operator: 'contains', value: '720P' }, + { operator: 'notContains', value: 'sample' } + ] + }; + + const result = applyFilenameFilter([ + { path: 'C:/Shows/Episode.720p.mkv', name: 'Episode.720p.mkv' }, + { path: 'C:/Shows/Episode.720p.Sample.mkv', name: 'Episode.720p.Sample.mkv' }, + { path: 'C:/Shows/Episode.1080p.mkv', name: 'Episode.1080p.mkv' } + ], filter); + + assert.deepEqual(result.accepted.map(file => file.name), ['Episode.720p.mkv']); + assert.deepEqual(result.excluded.map(file => file.name), ['Episode.720p.Sample.mkv', 'Episode.1080p.mkv']); + assert.equal(result.total, 3); + assert.equal(result.active, true); + }); + + it('supports matching any condition and excluding matching filenames', () => { + const filter = { + enabled: true, + action: 'exclude', + matchMode: 'any', + conditions: [ + { operator: 'contains', value: '1080p' }, + { operator: 'contains', value: 'sample' } + ] + }; + + assert.equal(evaluateFilenameFilter('Episode.720p.mkv', filter).accepted, true); + assert.equal(evaluateFilenameFilter('Episode.1080p.mkv', filter).accepted, false); + assert.equal(evaluateFilenameFilter('Episode.720p.Sample.mkv', filter).accepted, false); + }); + + it('normalizes unsupported values and derives names from paths', () => { + const normalized = normalizeFilenameFilter({ + enabled: true, + action: 'unknown', + matchMode: 'unknown', + conditions: [ + { operator: 'unknown', value: ' 720p ' }, + null, + { operator: 'contains', value: '' } + ] + }); + const result = applyFilenameFilter(['C:\\Shows\\Episode.720p.mkv', '/shows/Episode.1080p.mkv'], normalized); + + assert.deepEqual(normalized, { + enabled: true, + action: 'include', + matchMode: 'all', + conditions: [{ operator: 'contains', value: '720p' }] + }); + assert.deepEqual(result.accepted, ['C:\\Shows\\Episode.720p.mkv']); + assert.deepEqual(result.excluded, ['/shows/Episode.1080p.mkv']); + }); +}); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index a5bfad8..fd5cb88 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -74,6 +74,17 @@ test('translates duplicate desktop drop feedback to English', () => { assert.equal(translateText('Auswahl ist bereits in den Upload-Aufträgen.', 'en'), 'The selection is already in the upload jobs.'); }); +test('translates filename filter controls and import counts in both directions', () => { + const german = '1 von 3 Dateien werden hinzugefügt. 2 durch den Dateinamenfilter ausgeschlossen.'; + const english = '1 of 3 files will be added. 2 excluded by the filename filter.'; + + assert.equal(translateText('Dateinamen beim Hinzufügen filtern', 'en'), 'Filter filenames when adding files'); + assert.equal(translateText('Dateiname filtern', 'en'), 'Filter file name'); + assert.equal(translateText('enthält nicht', 'en'), 'does not contain'); + assert.equal(translateText(german, 'en'), english); + assert.equal(translateText(english, 'de'), german); +}); + test('rare account, backup, update, and confirmation states translate in both directions', () => { const cases = [ ['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'], diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index a1541f7..c946f0b 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -250,7 +250,7 @@ setTimeout(async () => { const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))'); check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History'); const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")'); - check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA'); + check('English upload telemetry is fully localized and ordered by relevance', englishTelemetryLabels === 'Remaining|Total|Running|Connections|Completed|Failed|Speed|ETA'); const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()'); check('English labels fit every main view without horizontal overflow', englishLayoutFits === true); const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()'); @@ -502,7 +502,7 @@ setTimeout(async () => { check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik'); const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")'); - check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA'); + check('Upload telemetry exposes all eight German labels in the requested order', localizedTelemetry === 'Verbleibend|Gesamt|Läuft|Verbindungen|Fertig|Fehler|Geschwindigkeit|ETA'); const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")'); check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--'); @@ -1432,6 +1432,25 @@ setTimeout(async () => { await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()'); const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()'); check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.'); + const filenameFilterControls = await wc.executeJavaScript('(() => ({ api: typeof window.FilenameFilter?.applyFilenameFilter, enabled: document.getElementById("filenameFilterEnabledInput")?.checked, action: document.getElementById("filenameFilterActionInput")?.value, match: document.getElementById("filenameFilterMatchModeInput")?.value, rows: document.querySelectorAll("[data-filename-filter-condition]").length, add: Boolean(document.getElementById("addFilenameFilterConditionBtn")) }))()'); + check('Filename filter starts disabled with a complete rule builder', filenameFilterControls.api === 'function' && filenameFilterControls.enabled === false && filenameFilterControls.action === 'include' && filenameFilterControls.match === 'all' && filenameFilterControls.rows === 1 && filenameFilterControls.add === true); + const filenameFilterGeometry = await wc.executeJavaScript('(() => { const rect = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height } : null; }; const valueLabel = rect("[data-filename-filter-value-label]"); const operatorLabel = rect("[data-filename-filter-operator-label]"); const value = rect("[data-filename-filter-value]"); const operator = rect("[data-filename-filter-operator]"); const remove = rect("[data-filename-filter-remove]"); const modeLabel = rect("label[for=filenameFilterMatchModeInput]"); const mode = rect("#filenameFilterMatchModeInput"); const actionLabel = rect("label[for=filenameFilterActionInput]"); const action = rect("#filenameFilterActionInput"); return { ruleLabelsAbove: valueLabel && operatorLabel && value && operator && valueLabel.bottom <= value.top - 4 && operatorLabel.bottom <= operator.top - 4, inputBeforeOperator: value && operator && value.left < operator.left, compactRule: value && operator && remove && operator.left - value.right >= 6 && operator.left - value.right <= 16 && remove.left - operator.right >= 6 && remove.left - operator.right <= 16, alignedRule: value && operator && remove && Math.abs(value.top - operator.top) <= 2 && Math.abs(operator.top - remove.top) <= 2 && Math.abs(value.height - operator.height) <= 2 && Math.abs(operator.height - remove.height) <= 2, policyBelowRule: value && mode && action && mode.top > value.bottom && action.top > value.bottom, policyOrder: mode && action && mode.left < action.left, policyLabelsAbove: modeLabel && mode && actionLabel && action && modeLabel.bottom <= mode.top - 4 && actionLabel.bottom <= action.top - 4, equalPolicyWidths: mode && action && Math.abs(mode.width - action.width) <= 2 }; })()'); + check('Filename filter follows value, comparison, conditions, then action', filenameFilterGeometry.ruleLabelsAbove && filenameFilterGeometry.inputBeforeOperator && filenameFilterGeometry.compactRule && filenameFilterGeometry.alignedRule && filenameFilterGeometry.policyBelowRule && filenameFilterGeometry.policyOrder && filenameFilterGeometry.policyLabelsAbove && filenameFilterGeometry.equalPolicyWidths); + const filenameFilterPersistence = await wc.executeJavaScript('(async () => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = true; enabled.dispatchEvent(new Event("change", { bubbles: true })); const first = document.querySelector("[data-filename-filter-condition]"); first.querySelector("[data-filename-filter-operator]").value = "contains"; first.querySelector("[data-filename-filter-value]").value = "720p"; first.querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("addFilenameFilterConditionBtn").click(); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[1].querySelector("[data-filename-filter-operator]").value = "notContains"; rows[1].querySelector("[data-filename-filter-value]").value = "sample"; rows[1].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); const dirty = document.getElementById("saveSettingsBtn").disabled === false; await saveSettings({ feedbackText: "Gespeichert" }); const saved = (await window.api.getGlobalSettings()).filenameFilter; return { dirty, saved }; })()'); + check('Filename filter conditions participate in dirty tracking and persist canonically', filenameFilterPersistence.dirty === true && filenameFilterPersistence.saved?.enabled === true && filenameFilterPersistence.saved?.action === 'include' && filenameFilterPersistence.saved?.matchMode === 'all' && JSON.stringify(filenameFilterPersistence.saved?.conditions) === JSON.stringify([{ operator: 'contains', value: '720p' }, { operator: 'notContains', value: 'sample' }])); + const filenameFilterImport = await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); addPathsToQueue([{ path: "C:/filter/Episode.720p.mkv", name: "Episode.720p.mkv", size: 10 }, { path: "C:/filter/Episode.720p.Sample.mkv", name: "Episode.720p.Sample.mkv", size: 11 }, { path: "C:/filter/Episode.1080p.mkv", name: "Episode.1080p.mkv", size: 12 }]); return { modal: document.getElementById("hosterModal")?.style.display, description: document.getElementById("hosterModalDescription")?.textContent, pending: _pendingFiles.map(file => file.name) }; })()'); + check('Filename filter previews accepted and excluded counts before host selection', filenameFilterImport.modal === 'flex' && filenameFilterImport.pending.length === 1 && filenameFilterImport.pending[0] === 'Episode.720p.mkv' && /1 von 3/.test(filenameFilterImport.description || '') && /2/.test(filenameFilterImport.description || '')); + const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { cancelHosterModal(); await addDropTargetEntries([{ path: "C:/filter/Floating.720p.mkv" }, { path: "C:/filter/Floating.1080p.mkv" }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: "C:/filter/Desktop.720p.mkv", name: "Desktop.720p.mkv", size: 12, type: "video/x-matroska" }, { path: "C:/filter/Desktop.1080p.mkv", name: "Desktop.1080p.mkv", size: 12, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()'); + check('Filename filter applies identically to floating and native desktop drops', JSON.stringify(filenameFilterDropPaths.floating) === JSON.stringify(['Floating.720p.mkv']) && JSON.stringify(filenameFilterDropPaths.desktop) === JSON.stringify(['Desktop.720p.mkv'])); + await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); config.globalSettings.folderMonitor = { ...(config.globalSettings.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false }; })()'); + wc.send('folder-monitor:new-files', ['C:/filter/Watched.720p.mkv', 'C:/filter/Watched.1080p.mkv']); + await waitUntil(() => wc.executeJavaScript('selectedFiles.length === 1')); + const filenameFilterFolderMonitor = await wc.executeJavaScript('(() => ({ files: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => job.fileName), modal: document.getElementById("hosterModal")?.style.display, toast: document.getElementById("copyToast")?.textContent }))()'); + check('Filename filter also applies to monitored folders with preset destinations', JSON.stringify(filenameFilterFolderMonitor.files) === JSON.stringify(['Watched.720p.mkv']) && filenameFilterFolderMonitor.jobs.every(name => name === 'Watched.720p.mkv') && filenameFilterFolderMonitor.modal !== 'flex' && /1 von 2/.test(filenameFilterFolderMonitor.toast || '')); + await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView()'); + const filenameFilterRejectAll = await wc.executeJavaScript('(() => { cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); addPathsToQueue([{ path: "C:/filter/Only.1080p.mkv", name: "Only.1080p.mkv", size: 10 }]); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: toast.textContent, shown: toast.classList.contains("show") }; })()'); + check('A fully excluded import stays out of the queue and explains the result', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && /0 von 1/.test(filenameFilterRejectAll.toast || '')); + await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()'); const plaintextCredentialOverride = await wc.executeJavaScript('(() => ({ control: document.getElementById("allowPlaintextCredentialStorageInput"), copy: document.body.textContent.includes("Unsichere Klartext-Speicherung"), bridge: typeof window.api.getSecretStoreStatus }))()'); check('Settings expose no plaintext credential storage override', plaintextCredentialOverride.control === null && plaintextCredentialOverride.copy === false && plaintextCredentialOverride.bridge === 'undefined'); const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()');