Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const state = {
lang: 'en',
fileType: 'allTypes', // 'images' | 'videos' | 'allTypes' | 'files' | 'music'
copyMode: 'allFiles', // 'allFiles' | 'dateRange'
folderMode: 'filesOnly', // 'filesOnly' | 'keepFolders'
dateFrom: null, // 'YYYY-MM-DD' | null
dateTo: null, // 'YYYY-MM-DD' | null
sourcePath: null, // selected phone path
Expand Down Expand Up @@ -88,6 +89,11 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.addEventListener('click', () => selectCopyMode(btn.dataset.value));
});

// Folder mode buttons
document.querySelectorAll('[data-group="folderMode"]').forEach(btn => {
btn.addEventListener('click', () => selectFolderMode(btn.dataset.value));
});

// Date range inputs
document.getElementById('date-from').addEventListener('change', e => {
state.dateFrom = e.target.value || null;
Expand Down Expand Up @@ -271,8 +277,7 @@ function selectFileType(type) {
});

const isFiles = type === 'files' || type === 'music';
document.querySelector('[data-group="copyMode"][data-value="allFiles"]').disabled = isFiles;
document.querySelector('[data-group="copyMode"][data-value="dateRange"]').hidden = isFiles;
document.getElementById('section-date-filter').hidden = isFiles;

// If date range was active when switching to Files, reset to all files
if (isFiles && state.copyMode === 'dateRange') {
Expand All @@ -293,6 +298,13 @@ function selectCopyMode(mode) {
}
}

function selectFolderMode(mode) {
state.folderMode = mode;
document.querySelectorAll('[data-group="folderMode"]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === mode);
});
}

function validateDateRange() {
const errEl = document.getElementById('date-range-error');
if (state.dateFrom && state.dateTo && state.dateFrom > state.dateTo) {
Expand Down Expand Up @@ -501,9 +513,10 @@ async function startTransfer() {
showScreen('transfer');

const body = {
sourceDir: state.sourcePath,
destDir: state.destPath,
fileType: state.fileType,
sourceDir: state.sourcePath,
destDir: state.destPath,
fileType: state.fileType,
folderMode: state.folderMode,
};
if (state.copyMode === 'dateRange') {
body.dateFrom = state.dateFrom;
Expand Down Expand Up @@ -607,8 +620,11 @@ function resetToStart() {
document.querySelectorAll('[data-group="copyMode"]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === 'allFiles');
});
document.querySelector('[data-group="copyMode"][data-value="allFiles"]').disabled = false;
document.querySelector('[data-group="copyMode"][data-value="dateRange"]').hidden = false;
state.folderMode = 'filesOnly';
document.querySelectorAll('[data-group="folderMode"]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === 'filesOnly');
});
document.getElementById('section-date-filter').hidden = false;
document.getElementById('date-range-row').hidden = true;
document.getElementById('date-from').value = '';
document.getElementById('date-to').value = '';
Expand Down
6 changes: 6 additions & 0 deletions public/i18n/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"selectPaths": "Vælg venligst både en kildemappe på telefonen og en destinationsmappe på denne computer, inden du starter.",
"modeAll": "Alle filer",
"modeDateRange": "Datointerval",
"folderModeFiles": "Kun filer",
"folderModeFolders": "Filer og mapper",
"sectionFileType": "Filtype",
"sectionDateFilter": "Datofilter",
"sectionStructure": "Struktur",
"sectionFolders": "Mapper",
"dateFrom": "Fra",
"dateTo": "Til",
"dateRangeError": "\"Fra\"-dato må ikke være efter \"Til\"-dato.",
Expand Down
6 changes: 6 additions & 0 deletions public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"selectPaths": "Please select both a source folder on the phone and a destination folder on this computer before starting.",
"modeAll": "All files",
"modeDateRange": "Date range",
"folderModeFiles": "Files",
"folderModeFolders": "Files & folders",
"sectionFileType": "File type",
"sectionDateFilter": "Date filter",
"sectionStructure": "Structure",
"sectionFolders": "Folders",
"dateFrom": "From",
"dateTo": "To",
"dateRangeError": "\"From\" date must not be after \"To\" date.",
Expand Down
6 changes: 6 additions & 0 deletions public/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"selectPaths": "Por favor, selecciona una carpeta de origen en el teléfono y una carpeta de destino en este ordenador antes de empezar.",
"modeAll": "Todos los archivos",
"modeDateRange": "Intervalo de fechas",
"folderModeFiles": "Solo archivos",
"folderModeFolders": "Archivos y carpetas",
"sectionFileType": "Tipo de archivo",
"sectionDateFilter": "Filtro de fecha",
"sectionStructure": "Estructura",
"sectionFolders": "Carpetas",
"dateFrom": "Desde",
"dateTo": "Hasta",
"dateRangeError": "La fecha \"Desde\" no puede ser posterior a \"Hasta\".",
Expand Down
75 changes: 46 additions & 29 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,45 +111,62 @@ <h2 data-i18n="connect.adbNotFound">ADB not found</h2>

<h2 data-i18n="setup.title">What would you like to copy?</h2>

<div class="type-buttons">
<button class="type-btn" data-group="fileType" data-value="images" data-i18n="setup.images">Images</button>
<button class="type-btn" data-group="fileType" data-value="videos" data-i18n="setup.videos">Videos</button>
<button class="type-btn" data-group="fileType" data-value="files" data-i18n="setup.files">Files</button>
<button class="type-btn" data-group="fileType" data-value="music" data-i18n="setup.music">Music</button>
<button class="type-btn active" data-group="fileType" data-value="allTypes" data-i18n="setup.allTypes">All Types</button>
</div>

<div class="type-buttons">
<button class="type-btn active" data-group="copyMode" data-value="allFiles" data-i18n="setup.modeAll">All files</button>
<button class="type-btn" data-group="copyMode" data-value="dateRange" data-i18n="setup.modeDateRange">Date range</button>
<div class="setup-section">
<p class="section-label" data-i18n="setup.sectionFileType">File type</p>
<div class="type-buttons">
<button class="type-btn" data-group="fileType" data-value="images" data-i18n="setup.images">Images</button>
<button class="type-btn" data-group="fileType" data-value="videos" data-i18n="setup.videos">Videos</button>
<button class="type-btn" data-group="fileType" data-value="files" data-i18n="setup.files">Files</button>
<button class="type-btn" data-group="fileType" data-value="music" data-i18n="setup.music">Music</button>
<button class="type-btn active" data-group="fileType" data-value="allTypes" data-i18n="setup.allTypes">All Types</button>
</div>
</div>

<div id="date-range-row" class="date-range-row" hidden>
<div class="date-field">
<label for="date-from" data-i18n="setup.dateFrom">From</label>
<input type="date" id="date-from">
<div class="setup-section" id="section-date-filter">
<p class="section-label" data-i18n="setup.sectionDateFilter">Date filter</p>
<div class="type-buttons">
<button class="type-btn active" data-group="copyMode" data-value="allFiles" data-i18n="setup.modeAll">All files</button>
<button class="type-btn" data-group="copyMode" data-value="dateRange" data-i18n="setup.modeDateRange">Date range</button>
</div>
<div class="date-field">
<label for="date-to" data-i18n="setup.dateTo">To</label>
<input type="date" id="date-to">
<div id="date-range-row" class="date-range-row" hidden>
<div class="date-field">
<label for="date-from" data-i18n="setup.dateFrom">From</label>
<input type="date" id="date-from">
</div>
<div class="date-field">
<label for="date-to" data-i18n="setup.dateTo">To</label>
<input type="date" id="date-to">
</div>
<p id="date-range-error" class="hint date-range-error" hidden></p>
</div>
<p id="date-range-error" class="hint date-range-error" hidden></p>
</div>

<div class="path-row">
<div class="path-info">
<label data-i18n="setup.sourceLabel">Source folder on phone</label>
<span id="source-path" class="path-display empty"></span>
<div class="setup-section">
<p class="section-label" data-i18n="setup.sectionStructure">Structure</p>
<div class="type-buttons">
<button class="type-btn active" data-group="folderMode" data-value="filesOnly" data-i18n="setup.folderModeFiles">Files</button>
<button class="type-btn" data-group="folderMode" data-value="keepFolders" data-i18n="setup.folderModeFolders">Files &amp; folders</button>
</div>
<button class="btn-secondary" id="btn-browse-phone" data-i18n="setup.browse">Browse</button>
</div>

<div class="path-row">
<div class="path-info">
<label data-i18n="setup.destLabel">Destination on this computer</label>
<span id="dest-path" class="path-display empty"></span>
<hr class="setup-divider">

<div class="setup-section">
<p class="section-label" data-i18n="setup.sectionFolders">Folders</p>
<div class="path-row">
<div class="path-info">
<label data-i18n="setup.sourceLabel">Source folder on phone</label>
<span id="source-path" class="path-display empty"></span>
</div>
<button class="btn-secondary" id="btn-browse-phone" data-i18n="setup.browse">Browse</button>
</div>
<div class="path-row">
<div class="path-info">
<label data-i18n="setup.destLabel">Destination on this computer</label>
<span id="dest-path" class="path-display empty"></span>
</div>
<button class="btn-secondary" id="btn-browse-local" data-i18n="setup.browse">Browse</button>
</div>
<button class="btn-secondary" id="btn-browse-local" data-i18n="setup.browse">Browse</button>
</div>

<p id="setup-hint" class="hint" hidden data-i18n="setup.selectPaths"></p>
Expand Down
20 changes: 20 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,26 @@ main {
line-height: 1.5;
}

/* ─── Setup sub-sections ─────────────────────────────────────────────────── */
.setup-section {
display: flex;
flex-direction: column;
gap: 10px;
}

.section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}

.setup-divider {
border: none;
border-top: 1px solid var(--border);
}

/* ─── File type buttons ──────────────────────────────────────────────────── */
.type-buttons {
display: flex;
Expand Down
11 changes: 9 additions & 2 deletions src/routes/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from 'express';
import WebSocket from 'ws';
import { TransferService, TransferOptions, ConflictAction } from '../services/TransferService';
import { TransferService, TransferOptions, ConflictAction, FolderMode } from '../services/TransferService';
import { AdbService } from '../services/AdbService';
import { ExifService } from '../services/ExifService';
import { FileService } from '../services/FileService';
Expand All @@ -26,7 +26,7 @@ export function createTransferRouter(getWs: () => WebSocket | null) {
}

router.post('/start', (req, res) => {
const { sourceDir, destDir, fileType, dateFrom, dateTo } = req.body as TransferOptions & { dateFrom?: string; dateTo?: string };
const { sourceDir, destDir, fileType, dateFrom, dateTo, folderMode } = req.body as TransferOptions & { dateFrom?: string; dateTo?: string };

if (!sourceDir || !destDir || !fileType) {
res.status(400).json({ error: 'sourceDir, destDir, and fileType are required' });
Expand All @@ -50,6 +50,12 @@ export function createTransferRouter(getWs: () => WebSocket | null) {
}
}

const VALID_FOLDER_MODES = new Set<string>(['filesOnly', 'keepFolders']);
if (folderMode != null && !VALID_FOLDER_MODES.has(folderMode as string)) {
res.status(400).json({ error: 'folderMode must be "filesOnly" or "keepFolders"' });
return;
}

// Cancel any running transfer before starting a new one
activeTransfer?.cancel();

Expand All @@ -67,6 +73,7 @@ export function createTransferRouter(getWs: () => WebSocket | null) {
const options: TransferOptions = {
sourceDir, destDir, fileType,
...(dateFrom && dateTo ? { dateFrom, dateTo } : {}),
...(folderMode ? { folderMode: folderMode as FolderMode } : {}),
};
activeTransfer.start(options).catch(err => {
send({ type: 'error', message: String(err) });
Expand Down
10 changes: 9 additions & 1 deletion src/services/TransferService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { FileService } from './FileService';

export type FileType = 'images' | 'videos' | 'allTypes' | 'files' | 'music';
export type ConflictAction = 'skip' | 'overwrite' | 'skip-all' | 'overwrite-all';
export type FolderMode = 'filesOnly' | 'keepFolders';

export interface TransferOptions {
sourceDir: string;
destDir: string;
fileType: FileType;
dateFrom?: string;
dateTo?: string;
folderMode?: FolderMode;
}

export interface ProgressEvent {
Expand Down Expand Up @@ -96,6 +98,7 @@ export class TransferService extends EventEmitter {
this.cancelled = false;

const { sourceDir, destDir, fileType } = options;
const folderMode = options.folderMode ?? 'filesOnly';
const tempDir = this.files.getTempDir();
await this.files.ensureDir(tempDir);

Expand Down Expand Up @@ -141,7 +144,12 @@ export class TransferService extends EventEmitter {
const ext = path.extname(phonePath).toLowerCase();

let destFolder: string;
if (FILE_EXTENSIONS.has(ext)) {
if (folderMode === 'keepFolders') {
// Preserve phone folder hierarchy relative to sourceDir.
// path.posix.relative handles forward-slash phone paths correctly on Windows.
const relDir = path.dirname(path.posix.relative(sourceDir, phonePath));
destFolder = path.join(destDir, relDir);
} else if (FILE_EXTENSIONS.has(ext)) {
destFolder = path.join(destDir, 'files');
} else if (MUSIC_EXTENSIONS.has(ext)) {
destFolder = path.join(destDir, 'music');
Expand Down
Loading