fix: embed base64 thumbnails in review HTML for instant page load
- Eliminates separate image requests (single-threaded bottleneck) - Pre-builds HTML with base64 data URIs before starting server - Adds "Keep first, select rest" per-group button - Sticky action bar, delete counter, progress feedback - 23 tests passing
This commit is contained in:
157
src/review.rs
157
src/review.rs
@@ -1,15 +1,19 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use deduper::{DuplicateGroup, DuplicateKind};
|
use deduper::{DuplicateGroup, DuplicateKind};
|
||||||
|
use image::imageops::FilterType;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tiny_http::{Header, Method, Response, Server};
|
use tiny_http::{Header, Method, Response, Server};
|
||||||
|
|
||||||
|
const THUMB_MAX: u32 = 300;
|
||||||
|
|
||||||
pub fn launch_review(groups: &[DuplicateGroup]) {
|
pub fn launch_review(groups: &[DuplicateGroup]) {
|
||||||
let port = find_open_port();
|
let port = find_open_port();
|
||||||
let addr = format!("127.0.0.1:{port}");
|
let addr = format!("127.0.0.1:{port}");
|
||||||
let server = Server::http(&addr).expect("failed to start review server");
|
let server = Server::http(&addr).expect("failed to start review server");
|
||||||
|
|
||||||
println!("review server running at http://{addr}");
|
eprintln!("building review page ({} groups)...", groups.len());
|
||||||
println!("opening browser...");
|
let html = build_review_html(groups);
|
||||||
|
eprintln!("review server running at http://{addr}");
|
||||||
|
|
||||||
let _ = open::that(format!("http://{addr}"));
|
let _ = open::that(format!("http://{addr}"));
|
||||||
|
|
||||||
@@ -20,16 +24,12 @@ pub fn launch_review(groups: &[DuplicateGroup]) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let url = req.url().to_string();
|
let url = req.url().to_string();
|
||||||
|
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
|
|
||||||
match (&method, url.as_str()) {
|
match (&method, url.as_str()) {
|
||||||
(Method::Get, "/") => {
|
(Method::Get, "/") => {
|
||||||
let html = build_review_html(groups);
|
|
||||||
let header = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap();
|
let header = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap();
|
||||||
let _ = req.respond(Response::from_string(html).with_header(header));
|
let _ = req.respond(Response::from_string(&html).with_header(header));
|
||||||
}
|
|
||||||
(Method::Get, path) if path.starts_with("/image/") => {
|
|
||||||
serve_image(req, path);
|
|
||||||
}
|
}
|
||||||
(Method::Post, "/delete") => {
|
(Method::Post, "/delete") => {
|
||||||
handle_delete(req);
|
handle_delete(req);
|
||||||
@@ -56,42 +56,12 @@ fn find_open_port() -> u16 {
|
|||||||
.port()
|
.port()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve_image(req: tiny_http::Request, path: &str) {
|
|
||||||
// path = /image/<base64-encoded-filepath>
|
|
||||||
let encoded = &path["/image/".len()..];
|
|
||||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
||||||
.decode(encoded)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let filepath = String::from_utf8_lossy(&decoded);
|
|
||||||
|
|
||||||
match fs::read(filepath.as_ref()) {
|
|
||||||
Ok(data) => {
|
|
||||||
let mime = guess_mime(&filepath);
|
|
||||||
let header = Header::from_bytes("Content-Type", mime).unwrap();
|
|
||||||
let _ = req.respond(Response::from_data(data).with_header(header));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = req.respond(Response::from_string("not found").with_status_code(404));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guess_mime(path: &str) -> &'static str {
|
|
||||||
let lower = path.to_ascii_lowercase();
|
|
||||||
if lower.ends_with(".png") { "image/png" }
|
|
||||||
else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { "image/jpeg" }
|
|
||||||
else if lower.ends_with(".gif") { "image/gif" }
|
|
||||||
else if lower.ends_with(".webp") { "image/webp" }
|
|
||||||
else if lower.ends_with(".bmp") { "image/bmp" }
|
|
||||||
else if lower.ends_with(".tif") || lower.ends_with(".tiff") { "image/tiff" }
|
|
||||||
else { "application/octet-stream" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_delete(mut req: tiny_http::Request) -> usize {
|
fn handle_delete(mut req: tiny_http::Request) -> usize {
|
||||||
let mut body = String::new();
|
let mut body = Vec::new();
|
||||||
req.as_reader().read_to_string(&mut body).unwrap_or(0);
|
let _ = std::io::Read::read_to_end(req.as_reader(), &mut body);
|
||||||
|
let body_str = String::from_utf8_lossy(&body);
|
||||||
|
|
||||||
let paths: Vec<String> = serde_json::from_str(&body).unwrap_or_default();
|
let paths: Vec<String> = serde_json::from_str(&body_str).unwrap_or_default();
|
||||||
let mut deleted = 0;
|
let mut deleted = 0;
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
match fs::remove_file(path) {
|
match fs::remove_file(path) {
|
||||||
@@ -111,10 +81,28 @@ fn handle_delete(mut req: tiny_http::Request) -> usize {
|
|||||||
deleted
|
deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_url(path: &std::path::Path) -> String {
|
fn make_thumbnail_data_uri(path: &std::path::Path) -> String {
|
||||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
let data = match fs::read(path) {
|
||||||
.encode(path.to_string_lossy().as_bytes());
|
Ok(d) => d,
|
||||||
format!("/image/{encoded}")
|
Err(_) => return String::from("data:image/png;base64,"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let img = match image::ImageReader::new(std::io::Cursor::new(&data))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()
|
||||||
|
.and_then(|r| r.decode().ok())
|
||||||
|
{
|
||||||
|
Some(i) => i,
|
||||||
|
None => return String::from("data:image/png;base64,"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb = img.resize(THUMB_MAX, THUMB_MAX, FilterType::Triangle);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
thumb
|
||||||
|
.write_to(&mut buf, image::ImageFormat::Jpeg)
|
||||||
|
.unwrap_or(());
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.into_inner());
|
||||||
|
format!("data:image/jpeg;base64,{b64}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_review_html(groups: &[DuplicateGroup]) -> String {
|
fn build_review_html(groups: &[DuplicateGroup]) -> String {
|
||||||
@@ -127,6 +115,7 @@ fn build_review_html(groups: &[DuplicateGroup]) -> String {
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
|
||||||
h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
|
h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
|
||||||
|
.summary { text-align: center; margin-bottom: 20px; color: #aaa; }
|
||||||
.group { background: #16213e; border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #0f3460; }
|
.group { background: #16213e; border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #0f3460; }
|
||||||
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
.group-title { font-size: 1.2em; font-weight: bold; }
|
.group-title { font-size: 1.2em; font-weight: bold; }
|
||||||
@@ -134,26 +123,35 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
|
|||||||
.badge-exact { background: #e94560; color: white; }
|
.badge-exact { background: #e94560; color: white; }
|
||||||
.badge-similar { background: #0f3460; color: #eee; border: 1px solid #533483; }
|
.badge-similar { background: #0f3460; color: #eee; border: 1px solid #533483; }
|
||||||
.images { display: flex; flex-wrap: wrap; gap: 16px; }
|
.images { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
.image-card { position: relative; background: #0f3460; border-radius: 8px; padding: 12px; max-width: 300px; flex: 1; min-width: 200px; }
|
.image-card { position: relative; background: #0f3460; border-radius: 8px; padding: 12px; max-width: 320px; flex: 1; min-width: 200px; }
|
||||||
.image-card img { width: 100%; height: 200px; object-fit: contain; border-radius: 4px; background: #000; }
|
.image-card img { width: 100%; height: 200px; object-fit: contain; border-radius: 4px; background: #000; cursor: pointer; }
|
||||||
.image-card .path { font-size: 0.75em; color: #aaa; margin-top: 8px; word-break: break-all; }
|
.image-card .path { font-size: 0.75em; color: #aaa; margin-top: 8px; word-break: break-all; }
|
||||||
.image-card .size { font-size: 0.8em; color: #888; margin-top: 4px; }
|
.image-card .size { font-size: 0.8em; color: #888; margin-top: 4px; }
|
||||||
.image-card label { display: flex; align-items: center; gap: 8px; margin-top: 8px; cursor: pointer; }
|
.image-card label { display: flex; align-items: center; gap: 8px; margin-top: 8px; cursor: pointer; }
|
||||||
.image-card input[type=checkbox] { width: 20px; height: 20px; accent-color: #e94560; }
|
.image-card input[type=checkbox] { width: 20px; height: 20px; accent-color: #e94560; }
|
||||||
.actions { text-align: center; margin-top: 30px; }
|
.actions { position: sticky; bottom: 0; background: #1a1a2e; padding: 16px; text-align: center; border-top: 2px solid #0f3460; }
|
||||||
.btn { padding: 12px 32px; border: none; border-radius: 8px; font-size: 1em; cursor: pointer; margin: 0 8px; }
|
.btn { padding: 12px 32px; border: none; border-radius: 8px; font-size: 1em; cursor: pointer; margin: 0 8px; }
|
||||||
.btn-delete { background: #e94560; color: white; }
|
.btn-delete { background: #e94560; color: white; }
|
||||||
.btn-delete:hover { background: #c73650; }
|
.btn-delete:hover { background: #c73650; }
|
||||||
.btn-done { background: #533483; color: white; }
|
.btn-done { background: #533483; color: white; }
|
||||||
.btn-done:hover { background: #3d2660; }
|
.btn-done:hover { background: #3d2660; }
|
||||||
|
.btn-all { background: #0f3460; color: #eee; border: 1px solid #533483; }
|
||||||
|
.btn-all:hover { background: #16213e; }
|
||||||
.status { text-align: center; margin-top: 16px; font-size: 1.1em; color: #e94560; }
|
.status { text-align: center; margin-top: 16px; font-size: 1.1em; color: #e94560; }
|
||||||
|
.deleted { opacity: 0.3; pointer-events: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>🔍 deduper — Review Duplicates</h1>
|
<h1>🔍 deduper — Review Duplicates</h1>
|
||||||
<p style="text-align:center;margin-bottom:20px;color:#aaa;">Select files to delete, then click "Delete Selected"</p>
|
|
||||||
"#);
|
"#);
|
||||||
|
|
||||||
|
let total_files: usize = groups.iter().map(|g| g.paths.len()).sum();
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<p class=\"summary\">{} groups, {} files — select files to delete</p>\n",
|
||||||
|
groups.len(),
|
||||||
|
total_files
|
||||||
|
));
|
||||||
|
|
||||||
for (idx, group) in groups.iter().enumerate() {
|
for (idx, group) in groups.iter().enumerate() {
|
||||||
let kind_str = match group.kind {
|
let kind_str = match group.kind {
|
||||||
DuplicateKind::Exact => "exact",
|
DuplicateKind::Exact => "exact",
|
||||||
@@ -165,29 +163,30 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
|
|||||||
};
|
};
|
||||||
|
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<div class="group">
|
r#"<div class="group" id="group-{idx}">
|
||||||
<div class="group-header">
|
<div class="group-header">
|
||||||
<span class="group-title">Group {}</span>
|
<span class="group-title">Group {}</span>
|
||||||
<span class="badge {badge_class}">{kind_str}</span>
|
<span class="badge {badge_class}">{kind_str}</span>
|
||||||
|
<button class="btn btn-all" onclick="selectAllBut('{idx}')">Keep first, select rest</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="images">
|
<div class="images">
|
||||||
"#,
|
"#,
|
||||||
idx + 1
|
idx + 1
|
||||||
));
|
));
|
||||||
|
|
||||||
for path in &group.paths {
|
for (pidx, path) in group.paths.iter().enumerate() {
|
||||||
let url = image_url(path);
|
let data_uri = make_thumbnail_data_uri(path);
|
||||||
let display_path = path.display();
|
let display_path = path.display();
|
||||||
let size = fs::metadata(path)
|
let size = fs::metadata(path)
|
||||||
.map(|m| format_size(m.len()))
|
.map(|m| format_size(m.len()))
|
||||||
.unwrap_or_else(|_| "?".to_string());
|
.unwrap_or_else(|_| "?".to_string());
|
||||||
|
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<div class="image-card">
|
r#"<div class="image-card" id="card-{idx}-{pidx}">
|
||||||
<img src="{url}" alt="{display_path}" loading="lazy">
|
<img src="{data_uri}" alt="{display_path}" title="Click to view full path">
|
||||||
<div class="path">{display_path}</div>
|
<div class="path">{display_path}</div>
|
||||||
<div class="size">{size}</div>
|
<div class="size">{size}</div>
|
||||||
<label><input type="checkbox" class="del-check" value="{display_path}"> Delete this</label>
|
<label><input type="checkbox" class="del-check" data-group="{idx}" value="{display_path}"> Delete this</label>
|
||||||
</div>
|
</div>
|
||||||
"#
|
"#
|
||||||
));
|
));
|
||||||
@@ -198,33 +197,49 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
|
|||||||
|
|
||||||
html.push_str(r#"
|
html.push_str(r#"
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-delete" onclick="deleteSelected()">🗑️ Delete Selected</button>
|
<button class="btn btn-delete" onclick="deleteSelected()">🗑️ Delete Selected (<span id="count">0</span>)</button>
|
||||||
<button class="btn btn-done" onclick="shutdown()">✅ Done</button>
|
<button class="btn btn-done" onclick="shutdown()">✅ Done</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function updateCount() {
|
||||||
|
document.getElementById('count').textContent =
|
||||||
|
document.querySelectorAll('.del-check:checked').length;
|
||||||
|
}
|
||||||
|
document.addEventListener('change', updateCount);
|
||||||
|
|
||||||
|
function selectAllBut(groupId) {
|
||||||
|
const checks = document.querySelectorAll(`[data-group="${groupId}"]`);
|
||||||
|
checks.forEach((c, i) => { c.checked = i > 0; });
|
||||||
|
updateCount();
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const checks = document.querySelectorAll('.del-check:checked');
|
const checks = document.querySelectorAll('.del-check:checked');
|
||||||
if (checks.length === 0) { alert('No files selected'); return; }
|
if (checks.length === 0) { alert('No files selected'); return; }
|
||||||
const paths = Array.from(checks).map(c => c.value);
|
const paths = Array.from(checks).map(c => c.value);
|
||||||
if (!confirm('Delete ' + paths.length + ' file(s)?\n\n' + paths.join('\n'))) return;
|
if (!confirm('Delete ' + paths.length + ' file(s)?\n\n' + paths.join('\n'))) return;
|
||||||
const res = await fetch('/delete', {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch('/delete', {
|
||||||
headers: {'Content-Type': 'application/json'},
|
method: 'POST',
|
||||||
body: JSON.stringify(paths)
|
headers: {'Content-Type': 'application/json'},
|
||||||
});
|
body: JSON.stringify(paths)
|
||||||
const data = await res.json();
|
});
|
||||||
document.getElementById('status').textContent = 'Deleted ' + data.deleted + ' file(s)';
|
const data = await res.json();
|
||||||
// Remove deleted cards
|
document.getElementById('status').textContent = 'Deleted ' + data.deleted + ' file(s)';
|
||||||
checks.forEach(c => {
|
checks.forEach(c => {
|
||||||
const card = c.closest('.image-card');
|
const card = c.closest('.image-card');
|
||||||
if (card) card.style.opacity = '0.3';
|
if (card) card.classList.add('deleted');
|
||||||
c.disabled = true;
|
});
|
||||||
});
|
updateCount();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('status').textContent = 'Error: ' + e.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
await fetch('/shutdown', {method: 'POST'});
|
try { await fetch('/shutdown', {method: 'POST'}); } catch(e) {}
|
||||||
document.getElementById('status').textContent = 'Review complete. You can close this tab.';
|
document.getElementById('status').textContent = 'Review complete. You can close this tab.';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user