Compare commits

...

5 Commits

8 changed files with 591 additions and 20 deletions

41
main.js
View File

@@ -1,13 +1,21 @@
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron');
const path = require('path');
const fs = require('fs-extra');
const { processFolder } = require('./services/folderProcessor');
const { processZip } = require('./services/zipProcessor');
const {
resolveUnroutedDir,
listUnroutedFiles,
listDuplicateFiles,
clearUnroutedFiles,
clearDuplicateFiles,
} = require('./services/unrouted');
const CAD_EXTENSIONS = ['prt', 'asm', 'dwr'];
const DEFAULT_DESTINATION = './output/cad';
const SETTINGS_FILENAME = 'cad-router-settings.json';
const LINUX_RUNTIME_DIR = '.cadroute';
function buildConfig(destination) {
const resolvedDestination = String(destination || '').trim() || DEFAULT_DESTINATION;
@@ -18,7 +26,19 @@ function buildConfig(destination) {
}
function getSettingsPath() {
return path.join(app.getPath('userData'), SETTINGS_FILENAME);
return path.join(getRuntimeDirectory(), SETTINGS_FILENAME);
}
function getRuntimeDirectory() {
if (process.platform === 'linux') {
return path.join(app.getPath('home'), LINUX_RUNTIME_DIR);
}
return app.getPath('userData');
}
async function ensureRuntimeDirectory() {
await fs.ensureDir(getRuntimeDirectory());
}
async function loadPersistedDestination() {
@@ -59,6 +79,7 @@ function createWindow() {
}
app.whenReady().then(async () => {
await ensureRuntimeDirectory();
const persistedDestination = await loadPersistedDestination();
config = buildConfig(persistedDestination);
createWindow();
@@ -154,3 +175,19 @@ ipcMain.handle('update-destination', async (_event, payload) => {
await persistDestination(config.destination);
return { destination: config.destination };
});
ipcMain.handle('open-unrouted-folder', async () => {
const unroutedDir = await resolveUnroutedDir();
const openResult = await shell.openPath(unroutedDir);
if (openResult) {
throw new Error(`Impossibile aprire la cartella: ${openResult}`);
}
return { path: unroutedDir };
});
ipcMain.handle('list-unrouted-files', async () => listUnroutedFiles());
ipcMain.handle('list-duplicates-files', async () => listDuplicateFiles());
ipcMain.handle('clear-unrouted-files', async () => clearUnroutedFiles());
ipcMain.handle('clear-duplicates-files', async () => clearDuplicateFiles());

View File

@@ -8,4 +8,9 @@ contextBridge.exposeInMainWorld('api', {
getDestination: () => ipcRenderer.invoke('get-destination'),
selectDestinationFolder: () => ipcRenderer.invoke('select-destination-folder'),
updateDestination: (destination) => ipcRenderer.invoke('update-destination', { destination }),
openUnroutedFolder: () => ipcRenderer.invoke('open-unrouted-folder'),
listUnroutedFiles: () => ipcRenderer.invoke('list-unrouted-files'),
listDuplicatesFiles: () => ipcRenderer.invoke('list-duplicates-files'),
clearUnroutedFiles: () => ipcRenderer.invoke('clear-unrouted-files'),
clearDuplicatesFiles: () => ipcRenderer.invoke('clear-duplicates-files'),
});

View File

@@ -133,6 +133,11 @@
margin-top: 10px;
}
.rule-row.preview-actions {
grid-template-columns: auto auto;
justify-content: start;
}
.rule-label {
font-size: 13px;
color: var(--muted);
@@ -161,6 +166,75 @@
color: var(--muted);
}
.preview-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: none;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 20;
}
.preview-overlay.visible {
display: flex;
}
.preview-card {
width: min(900px, 100%);
max-height: 85vh;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.preview-header h2 {
margin: 0;
font-size: 18px;
}
.danger {
background: #b91c1c;
}
.preview-meta {
font-size: 12px;
color: var(--muted);
word-break: break-all;
}
.preview-list {
margin: 0;
background: #0f172a;
color: #e2e8f0;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
overflow: auto;
min-height: 220px;
max-height: 58vh;
font-size: 13px;
white-space: pre-wrap;
}
@media (max-width: 760px) {
.rule-row {
grid-template-columns: 1fr;
@@ -200,11 +274,29 @@
<button id="browseDestinationBtn" class="browse">Sfoglia</button>
<button id="saveDestinationBtn">Salva</button>
</div>
<div class="rule-row single-row preview-actions">
<button id="openUnroutedBtn" class="browse">Anteprima non smistati</button>
<button id="openDuplicatesBtn" class="browse">Anteprima duplicati</button>
</div>
<div class="rule-status" id="destinationStatus"></div>
</section>
<pre id="output">Pronto.</pre>
</main>
<section class="preview-overlay" id="previewOverlay">
<article class="preview-card">
<div class="preview-header">
<h2 id="previewTitle">Anteprima</h2>
<div class="preview-header-actions">
<button id="clearPreviewBtn" class="danger">Pulisci cartella</button>
<button id="closePreviewBtn" class="secondary">Chiudi</button>
</div>
</div>
<div class="preview-meta" id="previewMeta"></div>
<pre class="preview-list" id="previewList">Nessun dato.</pre>
</article>
</section>
<script src="./renderer.js"></script>
</body>
</html>

View File

@@ -5,9 +5,18 @@ const output = document.getElementById('output');
const destinationInput = document.getElementById('destinationInput');
const browseDestinationBtn = document.getElementById('browseDestinationBtn');
const saveDestinationBtn = document.getElementById('saveDestinationBtn');
const openUnroutedBtn = document.getElementById('openUnroutedBtn');
const openDuplicatesBtn = document.getElementById('openDuplicatesBtn');
const destinationStatus = document.getElementById('destinationStatus');
const previewOverlay = document.getElementById('previewOverlay');
const closePreviewBtn = document.getElementById('closePreviewBtn');
const clearPreviewBtn = document.getElementById('clearPreviewBtn');
const previewTitle = document.getElementById('previewTitle');
const previewMeta = document.getElementById('previewMeta');
const previewList = document.getElementById('previewList');
const defaultDropZoneText = 'Trascina qui una cartella o un file .zip';
let isProcessing = false;
let currentPreviewKind = null;
function setLoading(isLoading) {
folderBtn.disabled = isLoading;
@@ -18,6 +27,52 @@ function setDestinationLoading(isLoading) {
destinationInput.disabled = isLoading;
browseDestinationBtn.disabled = isLoading;
saveDestinationBtn.disabled = isLoading;
openUnroutedBtn.disabled = isLoading;
openDuplicatesBtn.disabled = isLoading;
clearPreviewBtn.disabled = isLoading;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function showPreview(kind, title, payload, emptyMessage) {
const files = payload?.files || [];
const directory = payload?.directory || '';
const lines = files.length
? files.map((file) => `- ${file.relativePath} | ${formatBytes(file.size)}`).join('\n')
: emptyMessage;
currentPreviewKind = kind;
previewTitle.textContent = title;
previewMeta.textContent = `cartella: ${directory} | file totali: ${files.length}`;
previewList.textContent = lines;
previewOverlay.classList.add('visible');
}
function hidePreview() {
currentPreviewKind = null;
previewOverlay.classList.remove('visible');
}
async function loadPreviewData(kind) {
if (kind === 'duplicates') {
return window.api.listDuplicatesFiles();
}
return window.api.listUnroutedFiles();
}
async function clearPreviewData(kind) {
if (kind === 'duplicates') {
return window.api.clearDuplicatesFiles();
}
return window.api.clearUnroutedFiles();
}
function renderResult(title, result) {
@@ -33,6 +88,8 @@ function renderResult(title, result) {
`scansionati: ${result.scanned ?? 0}`,
`copiati: ${result.copied ?? 0}`,
`saltati: ${result.skipped ?? 0}`,
`non smistati: ${result.unrouted ?? 0}`,
`duplicati: ${result.duplicates ?? 0}`,
'',
'dettagli (max 20):',
detailsText,
@@ -165,6 +222,88 @@ saveDestinationBtn.addEventListener('click', async () => {
}
});
openUnroutedBtn.addEventListener('click', async () => {
try {
setDestinationLoading(true);
destinationStatus.textContent = 'Caricamento anteprima non smistati...';
const result = await window.api.listUnroutedFiles();
showPreview(
'unrouted',
'Anteprima __NON_SMISTATI',
result,
'Nessun file presente in __NON_SMISTATI.'
);
destinationStatus.textContent = `Anteprima caricata (${result.files?.length || 0} file).`;
} catch (error) {
destinationStatus.textContent = `Errore: ${error.message}`;
} finally {
setDestinationLoading(false);
}
});
openDuplicatesBtn.addEventListener('click', async () => {
try {
setDestinationLoading(true);
destinationStatus.textContent = 'Caricamento anteprima duplicati...';
const result = await window.api.listDuplicatesFiles();
showPreview('duplicates', 'Anteprima duplicati', result, 'Nessun file presente in duplicati.');
destinationStatus.textContent = `Anteprima caricata (${result.files?.length || 0} file).`;
} catch (error) {
destinationStatus.textContent = `Errore: ${error.message}`;
} finally {
setDestinationLoading(false);
}
});
clearPreviewBtn.addEventListener('click', async () => {
if (!currentPreviewKind) {
return;
}
const isDuplicates = currentPreviewKind === 'duplicates';
const confirmMessage = isDuplicates
? 'Confermi la pulizia della cartella duplicati?'
: 'Confermi la pulizia della cartella non smistati?';
if (!window.confirm(confirmMessage)) {
return;
}
try {
setDestinationLoading(true);
destinationStatus.textContent = 'Pulizia cartella in corso...';
const clearResult = await clearPreviewData(currentPreviewKind);
const refreshed = await loadPreviewData(currentPreviewKind);
const title = isDuplicates ? 'Anteprima duplicati' : 'Anteprima __NON_SMISTATI';
const emptyMessage = isDuplicates
? 'Nessun file presente in duplicati.'
: 'Nessun file presente in __NON_SMISTATI.';
showPreview(currentPreviewKind, title, refreshed, emptyMessage);
destinationStatus.textContent = `Cartella pulita: ${clearResult.directory}`;
} catch (error) {
destinationStatus.textContent = `Errore: ${error.message}`;
} finally {
setDestinationLoading(false);
}
});
closePreviewBtn.addEventListener('click', hidePreview);
previewOverlay.addEventListener('click', (event) => {
if (event.target === previewOverlay) {
hidePreview();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && previewOverlay.classList.contains('visible')) {
hidePreview();
}
});
async function initConfigUI() {
try {
const destinationResult = await window.api.getDestination();

View File

@@ -0,0 +1,55 @@
const fs = require('fs-extra');
const path = require('path');
const { getCadInfo } = require('./router');
const MAX_SCAN_DEPTH = 8;
function toCadKey(cadInfo) {
return String(cadInfo?.key || '').toLowerCase();
}
async function buildExistingCadKeyIndex(destinationRoot) {
const keys = new Set();
if (!destinationRoot || !(await fs.pathExists(destinationRoot))) {
return keys;
}
async function walk(currentDir, depth) {
if (depth > MAX_SCAN_DEPTH) {
return;
}
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath, depth + 1);
continue;
}
if (!entry.isFile()) {
continue;
}
const cadInfo = getCadInfo(entry.name);
if (!cadInfo) {
continue;
}
keys.add(toCadKey(cadInfo));
}
}
await walk(destinationRoot, 0);
return keys;
}
module.exports = { buildExistingCadKeyIndex, toCadKey };

View File

@@ -1,43 +1,90 @@
const fs = require('fs-extra');
const path = require('path');
const { getDestinationDecision, isCadFile } = require('./router');
const { getDestinationDecision, getCadInfo } = require('./router');
const { buildDestinationIndex } = require('./destinationIndex');
const { buildExistingCadKeyIndex, toCadKey } = require('./duplicateIndex');
const { getUnroutedTarget, getDuplicateTarget } = require('./unrouted');
async function collectFilesRecursively(rootDir) {
const files = [];
async function walk(currentDir) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (entry.isFile()) {
files.push({ fullPath, fileName: entry.name });
}
}
}
await walk(rootDir);
return files;
}
async function processFolder(folder, config) {
const entries = await fs.readdir(folder, { withFileTypes: true });
const files = await collectFilesRecursively(folder);
const destinationIndex = await buildDestinationIndex(config?.destination);
const existingCadKeys = await buildExistingCadKeyIndex(config?.destination);
const result = {
scanned: 0,
copied: 0,
skipped: 0,
unrouted: 0,
duplicates: 0,
details: [],
};
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const file = entry.name;
for (const { fullPath, fileName } of files) {
const file = fileName;
result.scanned += 1;
if (!isCadFile(file)) {
const cadInfo = getCadInfo(file);
if (!cadInfo) {
result.skipped += 1;
continue;
}
const src = path.join(folder, file);
if (existingCadKeys.has(toCadKey(cadInfo))) {
const duplicateTarget = await getDuplicateTarget(file);
await fs.copy(fullPath, duplicateTarget.destinationPath, { overwrite: false });
result.copied += 1;
result.duplicates += 1;
result.details.push({
file,
destination: duplicateTarget.destinationDir,
reason: 'Duplicato gia presente prima dello smistamento',
});
continue;
}
const decision = getDestinationDecision(file, config, destinationIndex);
const destDir = decision.destination;
if (!destDir) {
result.skipped += 1;
result.details.push({ file, reason: decision.reason || 'Nessuna regola trovata' });
const unroutedTarget = await getUnroutedTarget(file);
await fs.copy(fullPath, unroutedTarget.destinationPath, { overwrite: false });
result.copied += 1;
result.unrouted += 1;
result.details.push({
file,
destination: unroutedTarget.destinationDir,
reason: decision.reason || 'Nessuna regola trovata',
});
continue;
}
const dest = path.join(destDir, file);
await fs.copy(src, dest, { overwrite: true });
await fs.copy(fullPath, dest, { overwrite: true });
result.copied += 1;
result.details.push({ file, destination: destDir });

169
services/unrouted.js Normal file
View File

@@ -0,0 +1,169 @@
const fs = require('fs-extra');
const path = require('path');
const os = require('os');
const PRIMARY_UNROUTED_DIR = '/cadroute/__NON_SMISTATI';
const HOME_UNROUTED_DIR = path.join(os.homedir(), '.cadroute', '__NON_SMISTATI');
const PRIMARY_DUPLICATES_DIR = '/cadroute/duplicati';
const HOME_DUPLICATES_DIR = path.join(os.homedir(), '.cadroute', 'duplicati');
const SPECIAL_TARGETS = {
unrouted: {
primary: PRIMARY_UNROUTED_DIR,
fallback: HOME_UNROUTED_DIR,
},
duplicates: {
primary: PRIMARY_DUPLICATES_DIR,
fallback: HOME_DUPLICATES_DIR,
},
};
const resolvedDirs = new Map();
async function resolveTargetDir(kind) {
const target = SPECIAL_TARGETS[kind];
if (!target) {
throw new Error(`Target speciale non supportato: ${kind}`);
}
if (!resolvedDirs.has(kind)) {
resolvedDirs.set(
kind,
(async () => {
try {
await fs.ensureDir(target.primary);
return target.primary;
} catch {
await fs.ensureDir(target.fallback);
return target.fallback;
}
})()
);
}
return resolvedDirs.get(kind);
}
async function resolveUnroutedDir() {
return resolveTargetDir('unrouted');
}
async function resolveDuplicatesDir() {
return resolveTargetDir('duplicates');
}
async function getUniquePath(destinationDir, fileName) {
const parsed = path.parse(fileName);
let candidate = path.join(destinationDir, fileName);
let counter = 1;
while (await fs.pathExists(candidate)) {
candidate = path.join(destinationDir, `${parsed.name}__${counter}${parsed.ext}`);
counter += 1;
}
return candidate;
}
async function getTarget(kind, fileName) {
const destinationDir = await resolveTargetDir(kind);
const destinationPath = await getUniquePath(destinationDir, fileName);
return {
destinationDir,
destinationPath,
};
}
async function getUnroutedTarget(fileName) {
return getTarget('unrouted', fileName);
}
async function getDuplicateTarget(fileName) {
return getTarget('duplicates', fileName);
}
async function listFilesRecursively(rootDir) {
const files = [];
async function walk(currentDir) {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (!entry.isFile()) {
continue;
}
const stats = await fs.stat(fullPath).catch(() => null);
if (!stats) {
continue;
}
files.push({
name: entry.name,
relativePath: path.relative(rootDir, fullPath),
size: stats.size,
updatedAt: stats.mtime.toISOString(),
});
}
}
await walk(rootDir);
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'it'));
return files;
}
async function listTargetFiles(kind) {
const directory = await resolveTargetDir(kind);
const files = await listFilesRecursively(directory);
return { directory, files };
}
async function clearTargetFiles(kind) {
const directory = await resolveTargetDir(kind);
await fs.emptyDir(directory);
return { directory };
}
async function listUnroutedFiles() {
return listTargetFiles('unrouted');
}
async function listDuplicateFiles() {
return listTargetFiles('duplicates');
}
async function clearUnroutedFiles() {
return clearTargetFiles('unrouted');
}
async function clearDuplicateFiles() {
return clearTargetFiles('duplicates');
}
module.exports = {
getUnroutedTarget,
getDuplicateTarget,
resolveUnroutedDir,
resolveDuplicatesDir,
listUnroutedFiles,
listDuplicateFiles,
clearUnroutedFiles,
clearDuplicateFiles,
PRIMARY_UNROUTED_DIR,
HOME_UNROUTED_DIR,
PRIMARY_DUPLICATES_DIR,
HOME_DUPLICATES_DIR,
};

View File

@@ -2,16 +2,21 @@ const unzipper = require('unzipper');
const fs = require('fs-extra');
const path = require('path');
const { pipeline } = require('stream/promises');
const { getDestinationDecision, isCadFile } = require('./router');
const { getDestinationDecision, getCadInfo } = require('./router');
const { buildDestinationIndex } = require('./destinationIndex');
const { buildExistingCadKeyIndex, toCadKey } = require('./duplicateIndex');
const { getUnroutedTarget, getDuplicateTarget } = require('./unrouted');
async function processZip(zipPath, config) {
const stream = fs.createReadStream(zipPath).pipe(unzipper.Parse({ forceStream: true }));
const destinationIndex = await buildDestinationIndex(config?.destination);
const existingCadKeys = await buildExistingCadKeyIndex(config?.destination);
const result = {
scanned: 0,
copied: 0,
skipped: 0,
unrouted: 0,
duplicates: 0,
details: [],
};
@@ -25,19 +30,41 @@ async function processZip(zipPath, config) {
const baseName = path.basename(file);
result.scanned += 1;
if (!isCadFile(baseName)) {
const cadInfo = getCadInfo(baseName);
if (!cadInfo) {
result.skipped += 1;
entry.autodrain();
continue;
}
if (existingCadKeys.has(toCadKey(cadInfo))) {
const duplicateTarget = await getDuplicateTarget(baseName);
await pipeline(entry, fs.createWriteStream(duplicateTarget.destinationPath));
result.copied += 1;
result.duplicates += 1;
result.details.push({
file: baseName,
destination: duplicateTarget.destinationDir,
reason: 'Duplicato gia presente prima dello smistamento',
});
continue;
}
const decision = getDestinationDecision(baseName, config, destinationIndex);
const destDir = decision.destination;
if (!destDir) {
result.skipped += 1;
result.details.push({ file: baseName, reason: decision.reason || 'Nessuna regola trovata' });
entry.autodrain();
const unroutedTarget = await getUnroutedTarget(baseName);
await pipeline(entry, fs.createWriteStream(unroutedTarget.destinationPath));
result.copied += 1;
result.unrouted += 1;
result.details.push({
file: baseName,
destination: unroutedTarget.destinationDir,
reason: decision.reason || 'Nessuna regola trovata',
});
continue;
}