diff --git a/PLAN.md b/PLAN.md
index b8a70c2..fd55843 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -855,6 +855,18 @@ Phase 15 shared list component:
to the original 87-file count and byte total, left every real video untouched, unmounted
`/mnt/dashcam`, and kept the service active. Bulk delete uses the same validated per-file removal
path but was not run against the real card because it would intentionally erase all footage.
+- **Selected import enhancement and real-hardware verification (2026-08-09)**: added per-row
+ Import buttons, checkboxes, Select all/Clear selection, and Import selected so importing is no
+ longer all-or-nothing. The backend validates every selected path with the same mount-boundary
+ protections as deletion, de-duplicates selections, preserves relative folders, tolerates a
+ stale/missing file while importing the remaining valid selection, and keeps the existing
+ same-size skip behavior. Before committing, paused background dashcam uploads, created a
+ disposable `YARIS_OBD_IMPORT_TEST.MP4` on the physical card, selected it in the real mobile-width
+ UI, and confirmed exactly one file copied with identical contents. A row-level repeat correctly
+ skipped the existing copy; Select all selected all 88 visible rows and Clear selection reset the
+ action. Removed both disposable copies, restored uploads, and verified the card returned to its
+ original 87 files/29,615,111,758 bytes unmounted. The refined UI was then re-tested against a
+ real existing video: it reported one skip, cleared the checkbox, and re-enabled all controls.
### Phase 17: Local imported-video browser + player — 🚧 PLANNED
Same file-browser treatment for videos already imported to the Pi's own disk
diff --git a/app.py b/app.py
index 0fd6a3d..3c6e609 100644
--- a/app.py
+++ b/app.py
@@ -35,8 +35,22 @@ def dash_devices():
@app.route('/api/dash/import', methods=['POST'])
def dash_import():
- summary = app_dash.import_dashcam()
- return jsonify(summary)
+ body = request.get_json(silent=True)
+ if body is None:
+ relative_paths = None
+ elif isinstance(body, dict):
+ relative_paths = body.get("paths", [])
+ else:
+ relative_paths = []
+ summary = app_dash.import_dashcam(relative_paths=relative_paths)
+ status_code = (
+ 200
+ if summary["status"] == "ok"
+ else 400
+ if summary["status"] == "invalid_selection"
+ else 409
+ )
+ return jsonify(summary), status_code
@app.route('/api/dash/files')
def dash_files():
diff --git a/modules/app_dash.py b/modules/app_dash.py
index 11a30b7..17e0774 100644
--- a/modules/app_dash.py
+++ b/modules/app_dash.py
@@ -383,10 +383,8 @@ def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAU
return result
-def copy_files(source_folder, destination_folder):
- """Copy every video file found under source_folder into destination_folder,
- preserving its relative sub-folder path (dashcams often split files into
- date/event sub-folders - flattening them risks filename collisions).
+def copy_files(source_folder, destination_folder, relative_paths=None):
+ """Copy selected videos, or every video when relative_paths is omitted.
Files already present at the destination (same relative path, same size)
are skipped so re-running an import doesn't re-copy everything.
@@ -394,11 +392,43 @@ def copy_files(source_folder, destination_folder):
Returns a summary dict: {found, copied, skipped, errors}.
"""
os.makedirs(destination_folder, exist_ok=True)
+ errors = []
+
+ if relative_paths is None:
+ videos = find_video_files(source_folder)
+ else:
+ if not isinstance(relative_paths, list) or not relative_paths:
+ return {
+ "found": 0,
+ "copied": 0,
+ "skipped": 0,
+ "errors": ["Select at least one video to import."],
+ "invalid_selection": True,
+ }
+
+ videos = []
+ seen = set()
+ for relative_path in relative_paths:
+ if relative_path in seen:
+ continue
+ seen.add(relative_path)
+ try:
+ abs_path = _safe_video_path(source_folder, relative_path)
+ except ValueError as e:
+ return {
+ "found": 0,
+ "copied": 0,
+ "skipped": 0,
+ "errors": [str(e)],
+ "invalid_selection": True,
+ }
+ except FileNotFoundError:
+ errors.append(f"Video not found: {relative_path}")
+ continue
+ videos.append((abs_path, relative_path))
- videos = find_video_files(source_folder)
copied = 0
skipped = 0
- errors = []
for abs_path, rel_path in videos:
dest_path = os.path.join(destination_folder, rel_path)
@@ -417,7 +447,11 @@ def copy_files(source_folder, destination_folder):
return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors}
-def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_MOUNT_POINT):
+def import_dashcam(
+ destination_folder=DEFAULT_DESTINATION,
+ mount_point=DEFAULT_MOUNT_POINT,
+ relative_paths=None,
+):
"""Full detect -> mount -> copy -> unmount pipeline.
Returns a summary dict describing what happened, always unmounting the
@@ -440,8 +474,12 @@ def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_M
}
try:
- summary = copy_files(source, destination_folder)
- summary["status"] = "ok"
+ summary = copy_files(source, destination_folder, relative_paths)
+ summary["status"] = (
+ "invalid_selection" if summary.pop("invalid_selection", False) else "ok"
+ )
+ if summary["status"] == "invalid_selection":
+ summary["message"] = summary["errors"][0]
summary["device"] = device_path
return summary
finally:
diff --git a/readme.md b/readme.md
index 11c96b6..dca5d68 100644
--- a/readme.md
+++ b/readme.md
@@ -43,7 +43,8 @@ i have a dream
- 🎥 **Dashcam file retrieval** — detects a USB dashcam automatically (any brand/folder layout,
no hardcoded device path), mounts it read-only, and safely copies footage locally. Verified
against a real 28GB/87-file import. The web UI can also browse, sort, and delete individual or
- all videos directly from the connected dashcam with confirmed, temporary read-write operations
+ all videos directly from the connected dashcam with confirmed, temporary read-write operations,
+ plus import one file or any checkbox selection instead of always importing the whole card
- ☁️ **Remote upload** — a self-hosted server (Flask + Postgres, `server/`) receives trip/GPS
logs and dashcam footage over the internet (via Cloudflare Tunnel or a plain port-forward).
Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps
diff --git a/static/js/dash.js b/static/js/dash.js
index 8562b69..905082c 100644
--- a/static/js/dash.js
+++ b/static/js/dash.js
@@ -10,12 +10,16 @@ const importBtn = document.getElementById('dash-import-btn');
const fileSummary = document.getElementById('dash-file-summary');
const fileListBody = document.querySelector('#dashcam-file-list [data-data-list-body]');
const filesRefreshBtn = document.getElementById('dash-files-refresh-btn');
+const selectAllBtn = document.getElementById('dash-select-all-btn');
+const importSelectedBtn = document.getElementById('dash-import-selected-btn');
const deleteAllBtn = document.getElementById('dash-delete-all-btn');
+let importInProgress = false;
function fetchJson(url, options) {
return fetch(url, options).then(response => response.json().then(data => {
if (!response.ok) {
- throw new Error(data.message || data.status || `HTTP ${response.status}`);
+ const details = data.errors && data.errors.length ? data.errors.join('; ') : null;
+ throw new Error(data.message || details || data.status || `HTTP ${response.status}`);
}
return data;
}));
@@ -36,6 +40,33 @@ function createCell(text) {
return cell;
}
+function selectedPaths() {
+ return Array.from(fileListBody.querySelectorAll('[data-select-path]:checked'))
+ .map(checkbox => checkbox.dataset.selectPath);
+}
+
+function updateSelectionControls() {
+ const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]'));
+ const selectedCount = selectedPaths().length;
+ importSelectedBtn.disabled = importInProgress || selectedCount === 0;
+ selectAllBtn.disabled = checkboxes.length === 0;
+ selectAllBtn.textContent = checkboxes.length > 0 && selectedCount === checkboxes.length
+ ? 'Clear selection'
+ : 'Select all';
+ importSelectedBtn.innerHTML = selectedCount
+ ? `Import selected (${selectedCount})`
+ : 'Import selected';
+}
+
+function setImportBusy(busy) {
+ importInProgress = busy;
+ importBtn.disabled = busy;
+ fileListBody.querySelectorAll('[data-import-path]').forEach(button => {
+ button.disabled = busy;
+ });
+ updateSelectionControls();
+}
+
function renderDashcamFiles(summary) {
fileListBody.replaceChildren();
if (summary.status !== 'ok') {
@@ -51,12 +82,29 @@ function renderDashcamFiles(summary) {
row.dataset.path = file.path;
row.dataset.size = file.size;
row.dataset.modified = file.modified_at;
+
+ const selection = document.createElement('td');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.className = 'form-check-input';
+ checkbox.dataset.selectPath = file.path;
+ checkbox.setAttribute('aria-label', `Select ${file.path}`);
+ selection.appendChild(checkbox);
+ row.appendChild(selection);
+
row.appendChild(createCell(file.path));
row.appendChild(createCell(formatBytes(file.size)));
row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString()));
const actions = document.createElement('td');
actions.className = 'data-list-actions';
+ const importButton = document.createElement('button');
+ importButton.type = 'button';
+ importButton.className = 'btn btn-sm btn-outline-primary';
+ importButton.dataset.importPath = file.path;
+ importButton.innerHTML = 'Import';
+ actions.appendChild(importButton);
+
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'btn btn-sm btn-outline-danger';
@@ -73,6 +121,7 @@ function renderDashcamFiles(summary) {
fileSummary.textContent += ` (${summary.errors.length} file error(s))`;
}
deleteAllBtn.disabled = files.length === 0;
+ updateSelectionControls();
}
function refreshDashcamFiles() {
@@ -84,6 +133,7 @@ function refreshDashcamFiles() {
fileListBody.replaceChildren();
fileSummary.textContent = `Unable to list dashcam files: ${err.message}`;
deleteAllBtn.disabled = true;
+ updateSelectionControls();
})
.finally(() => {
filesRefreshBtn.disabled = false;
@@ -103,6 +153,38 @@ function deleteDashcamFile(path) {
});
}
+function describeImportResult(summary) {
+ let text = `Selected ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped} (already imported).`;
+ if (summary.errors && summary.errors.length) {
+ text += ` Errors: ${summary.errors.join('; ')}`;
+ }
+ return text;
+}
+
+function importDashcamFiles(paths) {
+ importStatus.textContent = `Importing ${paths.length} selected video(s)...`;
+ setImportBusy(true);
+ return fetchJson('/api/dash/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths }),
+ }).then(summary => {
+ importStatus.textContent = describeImportResult(summary);
+ paths.forEach(path => {
+ const checkbox = Array.from(
+ fileListBody.querySelectorAll('[data-select-path]')
+ ).find(candidate => candidate.dataset.selectPath === path);
+ if (checkbox) {
+ checkbox.checked = false;
+ }
+ });
+ return summary;
+ }).catch(err => {
+ importStatus.textContent = `Selected import failed: ${err.message}`;
+ return null;
+ }).finally(() => setImportBusy(false));
+}
+
function describeDevice(device) {
const label = device.label ? ` "${device.label}"` : '';
return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`;
@@ -130,7 +212,7 @@ function scanForDashcam() {
function importDashcam() {
importStatus.textContent = 'Importing... this can take a while for large videos.';
- importBtn.disabled = true;
+ setImportBusy(true);
fetch('/api/dash/import', { method: 'POST' })
.then(response => response.json())
@@ -150,7 +232,7 @@ function importDashcam() {
importStatus.textContent = 'Error while importing: ' + err;
})
.finally(() => {
- importBtn.disabled = false;
+ setImportBusy(false);
});
}
@@ -158,6 +240,12 @@ scanBtn.addEventListener('click', scanForDashcam);
importBtn.addEventListener('click', importDashcam);
filesRefreshBtn.addEventListener('click', refreshDashcamFiles);
fileListBody.addEventListener('click', event => {
+ const importButton = event.target.closest('[data-import-path]');
+ if (importButton) {
+ importDashcamFiles([importButton.dataset.importPath]);
+ return;
+ }
+
const deleteButton = event.target.closest('[data-delete-path]');
if (deleteButton) {
deleteButton.disabled = true;
@@ -166,6 +254,25 @@ fileListBody.addEventListener('click', event => {
});
}
});
+fileListBody.addEventListener('change', event => {
+ if (event.target.matches('[data-select-path]')) {
+ updateSelectionControls();
+ }
+});
+importSelectedBtn.addEventListener('click', () => {
+ const paths = selectedPaths();
+ if (paths.length) {
+ importDashcamFiles(paths);
+ }
+});
+selectAllBtn.addEventListener('click', () => {
+ const checkboxes = Array.from(fileListBody.querySelectorAll('[data-select-path]'));
+ const shouldSelect = checkboxes.some(checkbox => !checkbox.checked);
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = shouldSelect;
+ });
+ updateSelectionControls();
+});
deleteAllBtn.addEventListener('click', () => {
if (!window.confirm(deleteAllBtn.dataset.confirm)) {
return;
diff --git a/templates/dash.html b/templates/dash.html
index 149bb09..3aee7b1 100644
--- a/templates/dash.html
+++ b/templates/dash.html
@@ -25,6 +25,12 @@
+
+