Compare commits

...

4 Commits

Author SHA1 Message Date
65b1777a60 feat(router): instrada in sottocartella A/M_B e ottimizza cache destinazione
- Router: aggiunge campo `subfolder` a getCadInfo (91xxxxx → A, 92xxxxx → M_B);
  getDestinationDecision appende la sottocartella al percorso finale
- Scanner (fast path): legge A e M_B dentro ogni cartella a 3 cifre per
  il rilevamento duplicati, anziché la cartella a 3 cifre stessa
- Cache: persistenza su disco del folderIndex; cadKeyIndex sempre fresco
- isCacheStillValid: validazione a due livelli (top-level + cartelle a 3
  cifre) — rileva automaticamente nuove cartelle aggiunte da colleghi
- UI: rimosso pulsante "Riscansiona destinazione"; gestione cache completamente
  automatica e trasparente per l'utente
2026-03-16 16:17:32 +01:00
a49d338741 perf(scanner): ottimizza scansione destinazione con pass unico e barra determinata
Sostituisce le due scansioni sequenziali (cartelle + file CAD) con un
unico passaggio parallelo in services/destinationScanner.js. La lettura
di primo livello fornisce il totale delle sezioni, rendendo la barra
di progresso determinata (N / totale) durante l'analisi della destinazione.
Il label mostra contemporaneamente sezioni completate e file CAD trovati
2026-03-16 15:16:27 +01:00
b0bcea2f10 feat(ui): aggiunge barra di progresso durante lo smistamento
Mostra una progress bar con messaggio contestuale nelle 4 fasi:
scansione sorgente, analisi cartella destinazione (con contatore
cartelle), ricerca duplicati (con contatore file CAD), smistamento
(N / totale). La barra è indeterminata nelle prime tre fasi e
determinata durante la copia. Il canale IPC progress viene
aperto all'inizio e chiuso al termine dell'operazione
2026-03-16 14:54:23 +01:00
57f8b230b2 docs: aggiorna documentazione cartelle speciali con percorsi corretti
Aggiunge sezione __SALTATI nella guida utente e nella sidebar.
Corregge i percorsi di __NON_SMISTATI e __DUPLICATI (Windows: C:\cadroute\,
Linux: ~/.cadroute/) e aggiorna i riferimenti da duplicati/ a __DUPLICATI
in tutta la documentazione. Aggiorna README con i percorsi corretti
2026-03-16 14:33:19 +01:00
12 changed files with 471 additions and 34 deletions

View File

@@ -47,8 +47,8 @@ Gli artefatti vengono generati in `dist/`.
| `__DUPLICATI` | File CAD già presenti in destinazione | | `__DUPLICATI` | File CAD già presenti in destinazione |
| `__SALTATI` | File non CAD (PDF, immagini, ecc.) | | `__SALTATI` | File non CAD (PDF, immagini, ecc.) |
- **Windows:** `C:\cadroute\<cartella>\`
- **Linux:** `~/.cadroute/<cartella>/` - **Linux:** `~/.cadroute/<cartella>/`
- **Windows:** `%APPDATA%\CadRoute\<cartella>\`
## Autore ## Autore

22
main.js
View File

@@ -4,6 +4,7 @@ const fs = require('fs-extra');
const { processFolder } = require('./services/folderProcessor'); const { processFolder } = require('./services/folderProcessor');
const { processZip } = require('./services/zipProcessor'); const { processZip } = require('./services/zipProcessor');
const { setCacheDir, clearCache } = require('./services/destinationScanner');
const { const {
resolveUnroutedDir, resolveUnroutedDir,
listUnroutedFiles, listUnroutedFiles,
@@ -127,6 +128,7 @@ Menu.setApplicationMenu(Menu.buildFromTemplate([
app.whenReady().then(async () => { app.whenReady().then(async () => {
await ensureRuntimeDirectory(); await ensureRuntimeDirectory();
setCacheDir(getRuntimeDirectory());
const persistedDestination = await loadPersistedDestination(); const persistedDestination = await loadPersistedDestination();
config = buildConfig(persistedDestination); config = buildConfig(persistedDestination);
createWindow(); createWindow();
@@ -142,7 +144,7 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
}); });
ipcMain.handle('select-folder', async () => { ipcMain.handle('select-folder', async (event) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ['openDirectory'], properties: ['openDirectory'],
}); });
@@ -151,11 +153,12 @@ ipcMain.handle('select-folder', async () => {
return { canceled: true }; return { canceled: true };
} }
const routingResult = await processFolder(result.filePaths[0], config); const onProgress = (data) => event.sender.send('progress', data);
const routingResult = await processFolder(result.filePaths[0], config, onProgress);
return { canceled: false, ...routingResult }; return { canceled: false, ...routingResult };
}); });
ipcMain.handle('select-zip', async () => { ipcMain.handle('select-zip', async (event) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'Zip', extensions: ['zip'] }], filters: [{ name: 'Zip', extensions: ['zip'] }],
@@ -165,11 +168,12 @@ ipcMain.handle('select-zip', async () => {
return { canceled: true }; return { canceled: true };
} }
const routingResult = await processZip(result.filePaths[0], config); const onProgress = (data) => event.sender.send('progress', data);
const routingResult = await processZip(result.filePaths[0], config, onProgress);
return { canceled: false, ...routingResult }; return { canceled: false, ...routingResult };
}); });
ipcMain.handle('process-dropped-path', async (_event, payload) => { ipcMain.handle('process-dropped-path', async (event, payload) => {
const droppedPath = String(payload?.path || '').trim(); const droppedPath = String(payload?.path || '').trim();
if (!droppedPath) { if (!droppedPath) {
throw new Error('Percorso non valido'); throw new Error('Percorso non valido');
@@ -180,13 +184,15 @@ ipcMain.handle('process-dropped-path', async (_event, payload) => {
throw new Error('Percorso non trovato'); throw new Error('Percorso non trovato');
} }
const onProgress = (data) => event.sender.send('progress', data);
if (stats.isDirectory()) { if (stats.isDirectory()) {
const routingResult = await processFolder(droppedPath, config); const routingResult = await processFolder(droppedPath, config, onProgress);
return { canceled: false, sourceType: 'folder', ...routingResult }; return { canceled: false, sourceType: 'folder', ...routingResult };
} }
if (stats.isFile() && path.extname(droppedPath).toLowerCase() === '.zip') { if (stats.isFile() && path.extname(droppedPath).toLowerCase() === '.zip') {
const routingResult = await processZip(droppedPath, config); const routingResult = await processZip(droppedPath, config, onProgress);
return { canceled: false, sourceType: 'zip', ...routingResult }; return { canceled: false, sourceType: 'zip', ...routingResult };
} }
@@ -220,9 +226,11 @@ ipcMain.handle('update-destination', async (_event, payload) => {
config = buildConfig(destination); config = buildConfig(destination);
await persistDestination(config.destination); await persistDestination(config.destination);
await clearCache();
return { destination: config.destination }; return { destination: config.destination };
}); });
ipcMain.handle('open-unrouted-folder', async () => { ipcMain.handle('open-unrouted-folder', async () => {
const unroutedDir = await resolveUnroutedDir(); const unroutedDir = await resolveUnroutedDir();
const openResult = await shell.openPath(unroutedDir); const openResult = await shell.openPath(unroutedDir);

View File

@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('api', {
clearUnroutedFiles: () => ipcRenderer.invoke('clear-unrouted-files'), clearUnroutedFiles: () => ipcRenderer.invoke('clear-unrouted-files'),
clearDuplicatesFiles: () => ipcRenderer.invoke('clear-duplicates-files'), clearDuplicatesFiles: () => ipcRenderer.invoke('clear-duplicates-files'),
clearSkippedFiles: () => ipcRenderer.invoke('clear-skipped-files'), clearSkippedFiles: () => ipcRenderer.invoke('clear-skipped-files'),
onProgress: (cb) => ipcRenderer.on('progress', (_e, data) => cb(data)),
offProgress: () => ipcRenderer.removeAllListeners('progress'),
}); });

View File

@@ -27,6 +27,7 @@
<a href="#nomi">Convenzione nomi file</a> <a href="#nomi">Convenzione nomi file</a>
<a href="#nonsmistati">File non smistati</a> <a href="#nonsmistati">File non smistati</a>
<a href="#duplicati">File duplicati</a> <a href="#duplicati">File duplicati</a>
<a href="#saltati">File saltati</a>
</nav> </nav>
<main> <main>
@@ -62,7 +63,7 @@
<tr> <tr>
<td><span class="badge skip">altri</span></td> <td><span class="badge skip">altri</span></td>
<td></td> <td></td>
<td>Ignorati (PDF, immagini, documenti, ecc.)</td> <td>Copiati in <code>__SALTATI</code> (PDF, immagini, documenti, ecc.)</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -100,7 +101,7 @@
<ol class="steps"> <ol class="steps">
<li><span>Clicca <strong>Process Folder</strong> per selezionare una cartella, oppure <strong>Process ZIP</strong> per selezionare un archivio. In alternativa, trascina la cartella o il file <code>.zip</code> nell'area tratteggiata.</span></li> <li><span>Clicca <strong>Process Folder</strong> per selezionare una cartella, oppure <strong>Process ZIP</strong> per selezionare un archivio. In alternativa, trascina la cartella o il file <code>.zip</code> nell'area tratteggiata.</span></li>
<li><span>L'app scansiona tutti i file e identifica automaticamente quelli CAD.</span></li> <li><span>L'app scansiona tutti i file e identifica automaticamente quelli CAD.</span></li>
<li><span>I file CAD vengono copiati nella sottocartella di destinazione corrispondente. I file non CAD vengono saltati.</span></li> <li><span>I file CAD vengono copiati nella sottocartella di destinazione corrispondente. I file non CAD vengono copiati in <code>__SALTATI</code>.</span></li>
<li><span>Al termine vengono mostrate le statistiche nell'area di output.</span></li> <li><span>Al termine vengono mostrate le statistiche nell'area di output.</span></li>
</ol> </ol>
@@ -127,7 +128,7 @@
</div> </div>
<div class="stat-item muted"> <div class="stat-item muted">
<div class="stat-label">Saltati</div> <div class="stat-label">Saltati</div>
<div class="stat-desc">File ignorati perché non CAD (PDF, immagini, ecc.).</div> <div class="stat-desc">File non CAD copiati in <code>__SALTATI</code> (PDF, immagini, ecc.).</div>
</div> </div>
<div class="stat-item warn"> <div class="stat-item warn">
<div class="stat-label">Non smistati</div> <div class="stat-label">Non smistati</div>
@@ -172,7 +173,7 @@ Esempi:
</tr> </tr>
<tr> <tr>
<td>File già presente in destinazione</td> <td>File già presente in destinazione</td>
<td>Copiato in <code>duplicati/</code> per revisione</td> <td>Copiato in <code>__DUPLICATI</code> per revisione</td>
</tr> </tr>
<tr> <tr>
<td>Versione inferiore presente nella sorgente</td> <td>Versione inferiore presente nella sorgente</td>
@@ -210,8 +211,8 @@ Esempi:
<h3>Dove si trovano</h3> <h3>Dove si trovano</h3>
<p>I file non smistati vengono copiati nella cartella <code>__NON_SMISTATI</code>:</p> <p>I file non smistati vengono copiati nella cartella <code>__NON_SMISTATI</code>:</p>
<ul style="margin: 8px 0 12px 20px; color: var(--muted);"> <ul style="margin: 8px 0 12px 20px; color: var(--muted);">
<li><strong>Windows:</strong> <code>C:\cadroute\__NON_SMISTATI\</code></li>
<li><strong>Linux:</strong> <code>~/.cadroute/__NON_SMISTATI/</code></li> <li><strong>Linux:</strong> <code>~/.cadroute/__NON_SMISTATI/</code></li>
<li><strong>Windows:</strong> <code>%APPDATA%\CadRoute\__NON_SMISTATI\</code></li>
</ul> </ul>
<h3>Visualizzare e pulire</h3> <h3>Visualizzare e pulire</h3>
@@ -231,16 +232,16 @@ Esempi:
<h2>File duplicati</h2> <h2>File duplicati</h2>
<p>Un file CAD viene classificato come <strong>duplicato</strong> quando la sua chiave (<code>CODICE.tipo</code>, senza versione) è già presente nella cartella di destinazione al momento dello smistamento.</p> <p>Un file CAD viene classificato come <strong>duplicato</strong> quando la sua chiave (<code>CODICE.tipo</code>, senza versione) è già presente nella cartella di destinazione al momento dello smistamento.</p>
<p>I duplicati non sovrascrivono i file già presenti in destinazione: vengono messi da parte nella cartella <code>duplicati/</code> per permettere una revisione manuale.</p> <p>I duplicati non sovrascrivono i file già presenti in destinazione: vengono messi da parte nella cartella <code>__DUPLICATI</code> per permettere una revisione manuale.</p>
<h3>Dove si trovano</h3> <h3>Dove si trovano</h3>
<ul style="margin: 8px 0 12px 20px; color: var(--muted);"> <ul style="margin: 8px 0 12px 20px; color: var(--muted);">
<li><strong>Linux:</strong> <code>~/.cadroute/duplicati/</code></li> <li><strong>Windows:</strong> <code>C:\cadroute\__DUPLICATI\</code></li>
<li><strong>Windows:</strong> <code>%APPDATA%\CadRoute\duplicati\</code></li> <li><strong>Linux:</strong> <code>~/.cadroute/__DUPLICATI/</code></li>
</ul> </ul>
<h3>Gestione versioni nei duplicati</h3> <h3>Gestione versioni nei duplicati</h3>
<p>Anche all'interno della cartella <code>duplicati/</code> viene applicata la logica delle versioni: se arriva una versione più alta di un file già presente nei duplicati, la versione vecchia viene eliminata e sostituita con quella nuova.</p> <p>Anche all'interno della cartella <code>__DUPLICATI</code> viene applicata la logica delle versioni: se arriva una versione più alta di un file già presente nei duplicati, la versione vecchia viene eliminata e sostituita con quella nuova.</p>
<h3>Visualizzare e pulire</h3> <h3>Visualizzare e pulire</h3>
<ol class="steps"> <ol class="steps">
@@ -254,6 +255,31 @@ Esempi:
</div> </div>
</section> </section>
<!-- ── SALTATI ────────────────────────────── -->
<section id="saltati">
<h2>File saltati</h2>
<p>Un file viene classificato come <strong>saltato</strong> quando non è riconosciuto come file CAD, cioè non ha estensione <code>.prt</code>, <code>.asm</code> o <code>.drw</code>. Rientrano in questa categoria PDF, immagini, documenti Word e qualsiasi altro formato non CAD.</p>
<p>I file saltati non vengono ignorati completamente: vengono copiati nella cartella <code>__SALTATI</code> per permettere una verifica manuale.</p>
<h3>Dove si trovano</h3>
<ul style="margin: 8px 0 12px 20px; color: var(--muted);">
<li><strong>Windows:</strong> <code>C:\cadroute\__SALTATI\</code></li>
<li><strong>Linux:</strong> <code>~/.cadroute/__SALTATI/</code></li>
</ul>
<h3>Visualizzare e pulire</h3>
<ol class="steps">
<li><span>Clicca <strong>Anteprima saltati</strong> per aprire la lista dei file presenti.</span></li>
<li><span>Verifica che nessun file CAD sia finito qui per errore di denominazione.</span></li>
<li><span>Se vuoi svuotare la cartella, clicca <strong>Pulisci cartella</strong> (rosso) e conferma.</span></li>
</ol>
<div class="callout info">
<strong>Nota:</strong> la cartella <code>__SALTATI</code> è utile per individuare file CAD rinominati erroneamente o con estensione mancante.
</div>
</section>
</main> </main>
<script> <script>

View File

@@ -254,6 +254,53 @@
overflow: auto; overflow: auto;
min-height: 180px; min-height: 180px;
} }
.progress-container {
margin-top: 18px;
display: none;
}
.progress-label {
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
progress {
width: 100%;
height: 6px;
border-radius: 4px;
border: none;
appearance: none;
-webkit-appearance: none;
background: var(--border);
overflow: hidden;
}
progress::-webkit-progress-bar {
background: var(--border);
border-radius: 4px;
}
progress::-webkit-progress-value {
background: var(--accent);
border-radius: 4px;
transition: width 80ms ease;
}
progress:indeterminate {
background: linear-gradient(90deg, var(--border) 25%, var(--accent) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: progress-indeterminate 1.2s linear infinite;
}
@keyframes progress-indeterminate {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -270,6 +317,11 @@
Trascina qui una cartella o un file .zip Trascina qui una cartella o un file .zip
</section> </section>
<div class="progress-container" id="progressContainer">
<div class="progress-label" id="progressLabel">In corso...</div>
<progress id="progressBar"></progress>
</div>
<section class="rules"> <section class="rules">
<h2>Destinazione file CAD</h2> <h2>Destinazione file CAD</h2>
<div class="rule-row single-row"> <div class="rule-row single-row">

View File

@@ -15,10 +15,61 @@ const clearPreviewBtn = document.getElementById('clearPreviewBtn');
const previewTitle = document.getElementById('previewTitle'); const previewTitle = document.getElementById('previewTitle');
const previewMeta = document.getElementById('previewMeta'); const previewMeta = document.getElementById('previewMeta');
const previewList = document.getElementById('previewList'); const previewList = document.getElementById('previewList');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressLabel = document.getElementById('progressLabel');
const defaultDropZoneText = 'Trascina qui una cartella o un file .zip'; const defaultDropZoneText = 'Trascina qui una cartella o un file .zip';
let isProcessing = false; let isProcessing = false;
let currentPreviewKind = null; let currentPreviewKind = null;
function showProgress() {
progressContainer.style.display = 'block';
progressBar.removeAttribute('value');
progressLabel.textContent = 'In corso...';
}
function hideProgress() {
progressContainer.style.display = 'none';
}
let _indexFilesFound = 0;
function updateProgress({ phase, current, total, file, scanned, folder }) {
if (phase === 'index-cache') {
// folderIndex dalla cache — avvia scansione veloce dei file
progressBar.max = total || 1;
progressBar.value = 0;
progressLabel.textContent = 'Struttura dalla cache — ricerca file CAD in corso...';
return;
} else if (phase === 'scan') {
_indexFilesFound = 0;
progressLabel.textContent = 'Scansione file sorgente...';
progressBar.removeAttribute('value');
} else if (phase === 'index-dest') {
// scansione completa (nessuna cache)
progressBar.max = total;
progressBar.value = current;
const filesInfo = _indexFilesFound > 0 ? `${_indexFilesFound} file CAD trovati` : '';
progressLabel.textContent = `Analisi destinazione: ${current} / ${total}${folder ? `${folder}` : ''}${filesInfo}`;
} else if (phase === 'index-dup') {
if (current !== undefined && total !== undefined) {
// scansione veloce (cache attiva): current/total = cartelle processate
progressBar.max = total;
progressBar.value = current;
progressLabel.textContent = `Ricerca file CAD: ${current} / ${total} cartelle`;
} else {
// scansione completa: scanned = contatore file trovati
_indexFilesFound = scanned;
const dirInfo = progressBar.max > 0 ? `${progressBar.value} / ${progressBar.max}` : '';
progressLabel.textContent = `Analisi destinazione: ${dirInfo}${scanned} file CAD trovati`;
}
} else if (phase === 'copy') {
progressBar.max = total;
progressBar.value = current;
progressLabel.textContent = `Smistamento: ${current} / ${total}${file ? `${file}` : ''}`;
}
}
function setLoading(isLoading) { function setLoading(isLoading) {
folderBtn.disabled = isLoading; folderBtn.disabled = isLoading;
zipBtn.disabled = isLoading; zipBtn.disabled = isLoading;
@@ -101,6 +152,8 @@ async function handleAction(actionName, actionFn) {
isProcessing = true; isProcessing = true;
setLoading(true); setLoading(true);
showProgress();
window.api.onProgress(updateProgress);
output.textContent = `${actionName} in corso...`; output.textContent = `${actionName} in corso...`;
try { try {
@@ -115,6 +168,8 @@ async function handleAction(actionName, actionFn) {
} catch (error) { } catch (error) {
output.textContent = `${actionName} fallito:\n${error.message}`; output.textContent = `${actionName} fallito:\n${error.message}`;
} finally { } finally {
window.api.offProgress();
hideProgress();
setLoading(false); setLoading(false);
isProcessing = false; isProcessing = false;
} }
@@ -309,6 +364,7 @@ clearPreviewBtn.addEventListener('click', async () => {
} }
}); });
closePreviewBtn.addEventListener('click', hidePreview); closePreviewBtn.addEventListener('click', hidePreview);
previewOverlay.addEventListener('click', (event) => { previewOverlay.addEventListener('click', (event) => {
if (event.target === previewOverlay) { if (event.target === previewOverlay) {

View File

@@ -4,13 +4,15 @@ const path = require('path');
const CODE_FOLDER_REGEX = /^\d{3}$/; const CODE_FOLDER_REGEX = /^\d{3}$/;
const MAX_SCAN_DEPTH = 6; const MAX_SCAN_DEPTH = 6;
async function buildDestinationIndex(destinationRoot) { async function buildDestinationIndex(destinationRoot, onProgress) {
const index = new Map(); const index = new Map();
if (!destinationRoot || !(await fs.pathExists(destinationRoot))) { if (!destinationRoot || !(await fs.pathExists(destinationRoot))) {
return index; return index;
} }
let scanned = 0;
async function walk(currentDir, depth) { async function walk(currentDir, depth) {
if (depth > MAX_SCAN_DEPTH) { if (depth > MAX_SCAN_DEPTH) {
return; return;
@@ -29,6 +31,9 @@ async function buildDestinationIndex(destinationRoot) {
} }
const fullPath = path.join(currentDir, entry.name); const fullPath = path.join(currentDir, entry.name);
scanned += 1;
onProgress?.({ phase: 'index-dest', scanned, folder: entry.name });
if (CODE_FOLDER_REGEX.test(entry.name)) { if (CODE_FOLDER_REGEX.test(entry.name)) {
const rows = index.get(entry.name) || []; const rows = index.get(entry.name) || [];
rows.push(fullPath); rows.push(fullPath);

View File

@@ -0,0 +1,264 @@
const fs = require('fs-extra');
const path = require('path');
const { getCadInfo } = require('./router');
const CODE_FOLDER_REGEX = /^\d{3}$/;
const MAX_SCAN_DEPTH = 6;
const CACHE_FILENAME = 'dest-index.cache.json';
let _cacheDir = null;
function setCacheDir(dir) {
_cacheDir = dir;
}
function toCadKey(cadInfo) {
return String(cadInfo?.key || '').toLowerCase();
}
function getCacheFilePath() {
return _cacheDir ? path.join(_cacheDir, CACHE_FILENAME) : null;
}
// ── Cache su disco (solo folderIndex — struttura cartelle) ─────────────────
async function tryLoadFolderIndexCache(destinationRoot) {
const cacheFile = getCacheFilePath();
if (!cacheFile) return null;
try {
const raw = await fs.readJson(cacheFile);
if (raw?.destination !== destinationRoot) return null;
return { folderIndex: new Map(raw.folderIndex || []), savedAt: raw.savedAt };
} catch {
return null;
}
}
async function saveFolderIndexCache(destinationRoot, folderIndex) {
const cacheFile = getCacheFilePath();
if (!cacheFile) return;
try {
await fs.writeJson(cacheFile, {
destination: destinationRoot,
savedAt: new Date().toISOString(),
folderIndex: [...folderIndex.entries()],
});
} catch {
// cache non critica
}
}
async function clearCache() {
const cacheFile = getCacheFilePath();
if (!cacheFile) return;
try {
await fs.remove(cacheFile);
} catch {
// ignora
}
}
// ── Scansione completa (folderIndex + cadKeyIndex in un unico passaggio) ───
async function fullScan(destinationRoot, onProgress) {
const folderIndex = new Map();
const cadKeyIndex = new Set();
if (!destinationRoot || !(await fs.pathExists(destinationRoot))) {
return { folderIndex, cadKeyIndex };
}
let topEntries;
try {
topEntries = await fs.readdir(destinationRoot, { withFileTypes: true });
} catch {
return { folderIndex, cadKeyIndex };
}
const topDirs = topEntries
.filter((e) => e.isDirectory())
.map((e) => ({ name: e.name, fullPath: path.join(destinationRoot, e.name) }));
const total = topDirs.length;
let completed = 0;
let scannedFiles = 0;
for (const entry of topEntries) {
if (!entry.isFile()) continue;
const cadInfo = getCadInfo(entry.name);
if (cadInfo) {
scannedFiles += 1;
cadKeyIndex.add(toCadKey(cadInfo));
}
}
async function walkSubtree(dirPath, depth) {
if (depth > MAX_SCAN_DEPTH) return;
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return;
}
const subdirPromises = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (CODE_FOLDER_REGEX.test(entry.name)) {
const rows = folderIndex.get(entry.name) || [];
rows.push(fullPath);
folderIndex.set(entry.name, rows);
}
subdirPromises.push(walkSubtree(fullPath, depth + 1));
} else if (entry.isFile()) {
const cadInfo = getCadInfo(entry.name);
if (cadInfo) {
scannedFiles += 1;
onProgress?.({ phase: 'index-dup', scanned: scannedFiles, file: entry.name });
cadKeyIndex.add(toCadKey(cadInfo));
}
}
}
await Promise.all(subdirPromises);
}
await Promise.all(
topDirs.map(async ({ name, fullPath }) => {
onProgress?.({ phase: 'index-dest', current: completed, total, folder: name });
if (CODE_FOLDER_REGEX.test(name)) {
const rows = folderIndex.get(name) || [];
rows.push(fullPath);
folderIndex.set(name, rows);
}
await walkSubtree(fullPath, 1);
completed += 1;
onProgress?.({ phase: 'index-dest', current: completed, total, folder: name });
})
);
return { folderIndex, cadKeyIndex };
}
// ── Scansione veloce dei file (usa il folderIndex già noto dalla cache) ────
//
// Invece di traversare l'intero albero, legge direttamente SOLO le cartelle
// a 3 cifre già note. Questo riduce drasticamente le chiamate readdir su SMB.
async function scanCadKeysFromFolderIndex(folderIndex, onProgress) {
const cadKeyIndex = new Set();
const allFolderPaths = [...folderIndex.values()].flat();
const total = allFolderPaths.length;
let completed = 0;
await Promise.all(
allFolderPaths.map(async (folderPath) => {
for (const sub of ['A', 'M_B']) {
const subPath = path.join(folderPath, sub);
let entries;
try {
entries = await fs.readdir(subPath, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
const cadInfo = getCadInfo(entry.name);
if (cadInfo) {
cadKeyIndex.add(toCadKey(cadInfo));
}
}
}
completed += 1;
onProgress?.({ phase: 'index-dup', current: completed, total });
})
);
return cadKeyIndex;
}
// ── Entry point ────────────────────────────────────────────────────────────
async function isCacheStillValid(destinationRoot, cached) {
// Controlla le cartelle top-level E le cartelle a 3 cifre al loro interno.
// Costa 1 + N_top_level readdir — rileva nuove cartelle aggiunte da colleghi.
let currentEntries;
try {
currentEntries = await fs.readdir(destinationRoot, { withFileTypes: true });
} catch {
return false;
}
const currentDirNames = new Set(
currentEntries.filter((e) => e.isDirectory()).map((e) => e.name)
);
// Ricostruisce: topLevelDir → Set<group> dalla cache
const cachedGroupsByTopDir = new Map();
for (const [group, paths] of cached.folderIndex.entries()) {
for (const p of paths) {
const topDir = path.relative(destinationRoot, p).split(path.sep)[0];
if (!cachedGroupsByTopDir.has(topDir)) cachedGroupsByTopDir.set(topDir, new Set());
cachedGroupsByTopDir.get(topDir).add(group);
}
}
const cachedDirNames = new Set(cachedGroupsByTopDir.keys());
// Controlla top-level
for (const name of currentDirNames) {
if (!cachedDirNames.has(name)) return false;
}
for (const name of cachedDirNames) {
if (!currentDirNames.has(name)) return false;
}
// Controlla cartelle a 3 cifre dentro ogni top-level (solo nuove aggiunte)
for (const topDir of currentDirNames) {
let subEntries;
try {
subEntries = await fs.readdir(path.join(destinationRoot, topDir), { withFileTypes: true });
} catch {
return false;
}
const cachedGroups = cachedGroupsByTopDir.get(topDir) || new Set();
for (const e of subEntries) {
if (e.isDirectory() && CODE_FOLDER_REGEX.test(e.name) && !cachedGroups.has(e.name)) {
return false; // nuova cartella a 3 cifre rilevata
}
}
}
return true;
}
async function buildDestinationIndexes(destinationRoot, onProgress) {
if (!destinationRoot) {
return { folderIndex: new Map(), cadKeyIndex: new Set() };
}
const cached = await tryLoadFolderIndexCache(destinationRoot);
if (cached && (await isCacheStillValid(destinationRoot, cached))) {
// folderIndex dalla cache (istantaneo), cadKeyIndex sempre fresco
onProgress?.({ phase: 'index-cache', savedAt: cached.savedAt, total: cached.folderIndex.size });
const cadKeyIndex = await scanCadKeysFromFolderIndex(cached.folderIndex, onProgress);
return { folderIndex: cached.folderIndex, cadKeyIndex };
}
// Prima volta, cache invalidata o struttura cambiata: scansione completa
const result = await fullScan(destinationRoot, onProgress);
await saveFolderIndexCache(destinationRoot, result.folderIndex);
return result;
}
module.exports = { buildDestinationIndexes, setCacheDir, clearCache, toCadKey };

View File

@@ -8,13 +8,15 @@ function toCadKey(cadInfo) {
return String(cadInfo?.key || '').toLowerCase(); return String(cadInfo?.key || '').toLowerCase();
} }
async function buildExistingCadKeyIndex(destinationRoot) { async function buildExistingCadKeyIndex(destinationRoot, onProgress) {
const keys = new Set(); const keys = new Set();
if (!destinationRoot || !(await fs.pathExists(destinationRoot))) { if (!destinationRoot || !(await fs.pathExists(destinationRoot))) {
return keys; return keys;
} }
let scanned = 0;
async function walk(currentDir, depth) { async function walk(currentDir, depth) {
if (depth > MAX_SCAN_DEPTH) { if (depth > MAX_SCAN_DEPTH) {
return; return;
@@ -44,6 +46,8 @@ async function buildExistingCadKeyIndex(destinationRoot) {
continue; continue;
} }
scanned += 1;
onProgress?.({ phase: 'index-dup', scanned, file: entry.name });
keys.add(toCadKey(cadInfo)); keys.add(toCadKey(cadInfo));
} }
} }

View File

@@ -1,8 +1,7 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const { getDestinationDecision, getCadInfo } = require('./router'); const { getDestinationDecision, getCadInfo } = require('./router');
const { buildDestinationIndex } = require('./destinationIndex'); const { buildDestinationIndexes, toCadKey } = require('./destinationScanner');
const { buildExistingCadKeyIndex, toCadKey } = require('./duplicateIndex');
const { prepareUnroutedTarget, prepareDuplicateTarget, getSkippedTarget } = require('./unrouted'); const { prepareUnroutedTarget, prepareDuplicateTarget, getSkippedTarget } = require('./unrouted');
function parseNumericVersion(version) { function parseNumericVersion(version) {
@@ -66,10 +65,11 @@ async function collectFilesRecursively(rootDir) {
return files; return files;
} }
async function processFolder(folder, config) { async function processFolder(folder, config, onProgress) {
onProgress?.({ phase: 'scan' });
const files = await collectFilesRecursively(folder); const files = await collectFilesRecursively(folder);
const destinationIndex = await buildDestinationIndex(config?.destination);
const existingCadKeys = await buildExistingCadKeyIndex(config?.destination); const { folderIndex: destinationIndex, cadKeyIndex: existingCadKeys } = await buildDestinationIndexes(config?.destination, onProgress);
const sourceMaxVersions = buildHighestNumericVersionByCadKey(files); const sourceMaxVersions = buildHighestNumericVersionByCadKey(files);
const result = { const result = {
scanned: 0, scanned: 0,
@@ -80,9 +80,13 @@ async function processFolder(folder, config) {
details: [], details: [],
}; };
for (const { fullPath, fileName } of files) { const total = files.length;
for (let i = 0; i < files.length; i++) {
const { fullPath, fileName } = files[i];
const file = fileName; const file = fileName;
result.scanned += 1; result.scanned += 1;
onProgress?.({ phase: 'copy', current: i + 1, total, file });
const cadInfo = getCadInfo(file); const cadInfo = getCadInfo(file);
if (!cadInfo) { if (!cadInfo) {

View File

@@ -16,6 +16,9 @@ function getCadInfo(filename) {
const version = match[3] || ''; const version = match[3] || '';
const digits = rawCode.replace(/\D/g, ''); const digits = rawCode.replace(/\D/g, '');
const routingGroup = digits.length >= 5 ? digits.slice(2, 5) : null; const routingGroup = digits.length >= 5 ? digits.slice(2, 5) : null;
const subfolder = digits.length >= 2
? (digits[1] === '1' ? 'A' : digits[1] === '2' ? 'M_B' : null)
: null;
return { return {
code: rawCode, code: rawCode,
@@ -24,6 +27,7 @@ function getCadInfo(filename) {
version, version,
key: `${rawCode}.${type}`, key: `${rawCode}.${type}`,
routingGroup, routingGroup,
subfolder,
}; };
} }
@@ -57,7 +61,14 @@ function getDestinationDecision(filename, config, destinationIndex) {
}; };
} }
return { destination: candidates[0], reason: null }; if (!cadInfo.subfolder) {
return {
destination: null,
reason: `Prefisso non riconosciuto (${cadInfo.digits[1]}): atteso 91 (→ A) o 92 (→ M_B)`,
};
}
return { destination: path.join(candidates[0], cadInfo.subfolder), reason: null };
} }
function findDestination(filename, config, destinationIndex) { function findDestination(filename, config, destinationIndex) {

View File

@@ -3,8 +3,7 @@ const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const { pipeline } = require('stream/promises'); const { pipeline } = require('stream/promises');
const { getDestinationDecision, getCadInfo } = require('./router'); const { getDestinationDecision, getCadInfo } = require('./router');
const { buildDestinationIndex } = require('./destinationIndex'); const { buildDestinationIndexes, toCadKey } = require('./destinationScanner');
const { buildExistingCadKeyIndex, toCadKey } = require('./duplicateIndex');
const { prepareUnroutedTarget, prepareDuplicateTarget, getSkippedTarget } = require('./unrouted'); const { prepareUnroutedTarget, prepareDuplicateTarget, getSkippedTarget } = require('./unrouted');
function parseNumericVersion(version) { function parseNumericVersion(version) {
@@ -27,7 +26,7 @@ async function buildZipHighestNumericVersionByCadKey(zipPath) {
try { try {
directory = await unzipper.Open.file(zipPath); directory = await unzipper.Open.file(zipPath);
} catch { } catch {
return index; return { index, total: 0 };
} }
for (const row of directory.files || []) { for (const row of directory.files || []) {
@@ -53,14 +52,16 @@ async function buildZipHighestNumericVersionByCadKey(zipPath) {
} }
} }
return index; return { index, total: (directory.files || []).filter((f) => f.type === 'File').length };
} }
async function processZip(zipPath, config) { async function processZip(zipPath, config, onProgress) {
onProgress?.({ phase: 'scan' });
const { index: sourceMaxVersions, total } = await buildZipHighestNumericVersionByCadKey(zipPath);
const { folderIndex: destinationIndex, cadKeyIndex: existingCadKeys } = await buildDestinationIndexes(config?.destination, onProgress);
const stream = fs.createReadStream(zipPath).pipe(unzipper.Parse({ forceStream: true })); const stream = fs.createReadStream(zipPath).pipe(unzipper.Parse({ forceStream: true }));
const destinationIndex = await buildDestinationIndex(config?.destination);
const existingCadKeys = await buildExistingCadKeyIndex(config?.destination);
const sourceMaxVersions = await buildZipHighestNumericVersionByCadKey(zipPath);
const result = { const result = {
scanned: 0, scanned: 0,
copied: 0, copied: 0,
@@ -70,6 +71,8 @@ async function processZip(zipPath, config) {
details: [], details: [],
}; };
let current = 0;
for await (const entry of stream) { for await (const entry of stream) {
if (entry.type !== 'File') { if (entry.type !== 'File') {
entry.autodrain(); entry.autodrain();
@@ -78,7 +81,9 @@ async function processZip(zipPath, config) {
const file = entry.path; const file = entry.path;
const baseName = path.basename(file); const baseName = path.basename(file);
current += 1;
result.scanned += 1; result.scanned += 1;
onProgress?.({ phase: 'copy', current, total, file: baseName });
const cadInfo = getCadInfo(baseName); const cadInfo = getCadInfo(baseName);
if (!cadInfo) { if (!cadInfo) {