manbatman
This commit is contained in:
parent
67c7394114
commit
c560579285
|
|
@ -0,0 +1,45 @@
|
|||
# Copilot Instructions for `o8_pics_size`
|
||||
|
||||
## Build, test, and lint commands
|
||||
|
||||
- Build: `cargo build`
|
||||
- Run app: `cargo run`
|
||||
- Run all tests: `cargo test`
|
||||
- Run a single test: `cargo test <test_name>`
|
||||
- Formatting check: `cargo fmt --check`
|
||||
- Lint (if available in your toolchain): `cargo clippy -- -D warnings`
|
||||
|
||||
## High-level architecture
|
||||
|
||||
The application is a single-binary Rust CLI (`src/main.rs`) built as a staged pipeline:
|
||||
|
||||
1. Pick an input Excel file (`.xls`/`.xlsx`) via `rfd::FileDialog`.
|
||||
2. Read workbook/sheet data with `calamine`.
|
||||
3. Use terminal UI selectors (`dialoguer::Select`) to choose:
|
||||
- sheet
|
||||
- `Cikkszám` column
|
||||
- `Url` column
|
||||
- `img_sequence` column
|
||||
4. Build input rows from sheet data (header row is row 0, data starts from row 1).
|
||||
5. Download images from URLs using blocking `reqwest`.
|
||||
6. Decode image bytes with `image` to extract dimensions; compute size from downloaded byte length.
|
||||
7. Export results to a new workbook with `rust_xlsxwriter`, saved as `result_[uuid].xlsx` next to the input file.
|
||||
|
||||
Core helper responsibilities are split into functions in `main.rs`:
|
||||
- selection helpers: `prompt_sheet_selection`, `prompt_column_selection`
|
||||
- sheet parsing helpers: `extract_headers`, `collect_input_rows`, `cell_string`
|
||||
- image metadata fetch: `fetch_image_metadata`
|
||||
- output generation: `build_output_path`, `write_results_excel`
|
||||
|
||||
## Key conventions in this repository
|
||||
|
||||
- **Fail-fast error handling:** most failures print a clear `eprintln!` message and exit with status 1.
|
||||
- **Column naming is contract-like:** output headers are currently written as:
|
||||
- `Cikkszám`
|
||||
- `Sqeuence` (keep this spelling unless intentionally changed)
|
||||
- `Url`
|
||||
- `Width (px)`
|
||||
- `Height (px)`
|
||||
- `Size (KB)`
|
||||
- **Interactive flow is TUI-driven:** keep sheet/column selection cursor-based (`dialoguer::Select`), not numeric text prompts.
|
||||
- **Output path pattern is fixed:** write result files as `result_[uuid].xlsx` in the same directory as the source workbook.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.DS_Store
|
||||
in/
|
||||
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "o8_pics_size"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
calamine = "0.31"
|
||||
dialoguer = "0.11"
|
||||
image = "0.25"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
rfd = "0.15"
|
||||
rust_xlsxwriter = "0.83"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
18
README.md
18
README.md
|
|
@ -1 +1,19 @@
|
|||
# o8_pics_size
|
||||
|
||||
Run the app to open a file picker limited to Excel files (`.xls`, `.xlsx`).
|
||||
After selecting a file, it reads the workbook sheet names and opens a terminal picker where you choose a sheet with arrow keys and Enter.
|
||||
Then it reads the header row and asks you to map three columns using the same TUI picker:
|
||||
- `Cikkszám`
|
||||
- `Url`
|
||||
- `img_sequence`
|
||||
|
||||
After mapping, it downloads each image from the selected `Url` column, reads image metadata, and writes a new Excel file next to the source workbook as `result_[uuid].xlsx`.
|
||||
For testing, it currently processes only the first 100 non-empty URL rows.
|
||||
|
||||
Output columns:
|
||||
- `Cikkszám`
|
||||
- `Sqeuence`
|
||||
- `Url`
|
||||
- `Width`
|
||||
- `Height`
|
||||
- `Size` (KB)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration
|
||||
};
|
||||
use calamine::{Data, Range, Reader, open_workbook_auto};
|
||||
use dialoguer::{Select, theme::ColorfulTheme};
|
||||
use image::GenericImageView;
|
||||
use reqwest::blocking::Client;
|
||||
use rust_xlsxwriter::Workbook;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn main() {
|
||||
let Some(file) = rfd::FileDialog::new()
|
||||
.add_filter("Excel files", &["xls", "xlsx"])
|
||||
.pick_file()
|
||||
else {
|
||||
eprintln!("No Excel file selected.");
|
||||
return;
|
||||
};
|
||||
|
||||
println!("Selected Excel file: {}", file.display());
|
||||
|
||||
let mut workbook = match open_workbook_auto(&file) {
|
||||
Ok(workbook) => workbook,
|
||||
Err(err) => {
|
||||
eprintln!("Failed to open workbook {}: {}", file.display(), err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let sheet_names = workbook.sheet_names().to_vec();
|
||||
if sheet_names.is_empty() {
|
||||
eprintln!("No sheets found in workbook {}.", file.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let selected_sheet = prompt_sheet_selection(&sheet_names);
|
||||
println!("\nSelected sheet: {}", selected_sheet);
|
||||
|
||||
let range = match workbook.worksheet_range(selected_sheet) {
|
||||
Ok(range) => range,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Failed to read sheet '{}' in {}: {}",
|
||||
selected_sheet,
|
||||
file.display(),
|
||||
err
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let headers = extract_headers(&range);
|
||||
if headers.is_empty() {
|
||||
eprintln!("No header row found in sheet '{}'.", selected_sheet);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let cikkszam_idx = prompt_column_selection(&headers, "Select column for 'Cikkszám'", &[]);
|
||||
let url_idx = prompt_column_selection(&headers, "Select column for 'Url'", &[cikkszam_idx]);
|
||||
let img_sequence_idx = prompt_column_selection(
|
||||
&headers,
|
||||
"Select column for 'img_sequence'",
|
||||
&[cikkszam_idx, url_idx],
|
||||
);
|
||||
|
||||
println!(
|
||||
"\nSelected columns:\n- Cikkszám: {} ({})\n- Url: {} ({})\n- img_sequence: {} ({})",
|
||||
headers[cikkszam_idx],
|
||||
column_label(cikkszam_idx),
|
||||
headers[url_idx],
|
||||
column_label(url_idx),
|
||||
headers[img_sequence_idx],
|
||||
column_label(img_sequence_idx)
|
||||
);
|
||||
|
||||
let input_rows = collect_input_rows(&range, cikkszam_idx, img_sequence_idx, url_idx);
|
||||
if input_rows.is_empty() {
|
||||
eprintln!("No data rows with URL values were found.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Processing first {} rows for testing.", input_rows.len());
|
||||
|
||||
let client = match Client::builder()
|
||||
.user_agent("o8_pics_size/0.1")
|
||||
.timeout(Duration::from_secs(45))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("Failed to initialize HTTP client: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let mut output_rows = Vec::new();
|
||||
let total = input_rows.len();
|
||||
for (index, row) in input_rows.iter().enumerate() {
|
||||
println!("[{}/{}] Fetching {}", index + 1, total, row.url);
|
||||
let metadata = match fetch_image_metadata(&client, &row.url) {
|
||||
Ok(metadata) => Some(metadata),
|
||||
Err(err) => {
|
||||
eprintln!("Failed to fetch '{}': {}", row.url, err);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
output_rows.push(OutputRow {
|
||||
cikkszam: row.cikkszam.clone(),
|
||||
sequence: row.sequence.clone(),
|
||||
url: row.url.clone(),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
let output_path = build_output_path(&file);
|
||||
if let Err(err) = write_results_excel(&output_path, &output_rows) {
|
||||
eprintln!(
|
||||
"Failed to write output workbook {}: {}",
|
||||
output_path.display(),
|
||||
err
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("\nCreated output workbook: {}", output_path.display());
|
||||
}
|
||||
|
||||
fn prompt_sheet_selection(sheet_names: &[String]) -> &str {
|
||||
let theme = ColorfulTheme::default();
|
||||
let selection = match Select::with_theme(&theme)
|
||||
.with_prompt("Select a sheet")
|
||||
.items(sheet_names)
|
||||
.default(0)
|
||||
.interact()
|
||||
{
|
||||
Ok(index) => index,
|
||||
Err(err) => {
|
||||
eprintln!("Failed to select sheet: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match sheet_names.get(selection) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
eprintln!("Invalid sheet selection index: {}", selection);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_headers(range: &Range<Data>) -> Vec<String> {
|
||||
let Some(first_row) = range.rows().next() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
first_row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, cell)| {
|
||||
let value = cell.to_string().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
format!("Unnamed {}", column_label(index))
|
||||
} else {
|
||||
value
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prompt_column_selection(headers: &[String], prompt: &str, excluded_indices: &[usize]) -> usize {
|
||||
let mut option_labels = Vec::new();
|
||||
let mut option_indices = Vec::new();
|
||||
|
||||
for (index, header) in headers.iter().enumerate() {
|
||||
if excluded_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
option_labels.push(format!("{} ({})", header, column_label(index)));
|
||||
option_indices.push(index);
|
||||
}
|
||||
|
||||
if option_indices.is_empty() {
|
||||
eprintln!("No selectable columns available for '{}'.", prompt);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let theme = ColorfulTheme::default();
|
||||
let selected = match Select::with_theme(&theme)
|
||||
.with_prompt(prompt)
|
||||
.items(&option_labels)
|
||||
.default(0)
|
||||
.interact()
|
||||
{
|
||||
Ok(index) => index,
|
||||
Err(err) => {
|
||||
eprintln!("Failed to select column: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
option_indices[selected]
|
||||
}
|
||||
|
||||
fn column_label(mut index: usize) -> String {
|
||||
let mut label = String::new();
|
||||
|
||||
loop {
|
||||
let remainder = index % 26;
|
||||
label.insert(0, (b'A' + remainder as u8) as char);
|
||||
index /= 26;
|
||||
|
||||
if index == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
label
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct InputRow {
|
||||
cikkszam: String,
|
||||
sequence: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ImageMetadata {
|
||||
width: u32,
|
||||
height: u32,
|
||||
size_kb: f64,
|
||||
}
|
||||
|
||||
struct OutputRow {
|
||||
cikkszam: String,
|
||||
sequence: String,
|
||||
url: String,
|
||||
metadata: Option<ImageMetadata>,
|
||||
}
|
||||
|
||||
fn collect_input_rows(
|
||||
range: &Range<Data>,
|
||||
cikkszam_idx: usize,
|
||||
sequence_idx: usize,
|
||||
url_idx: usize,
|
||||
) -> Vec<InputRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for row in range.rows().skip(1) {
|
||||
let url = cell_string(row.get(url_idx));
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(InputRow {
|
||||
cikkszam: cell_string(row.get(cikkszam_idx)),
|
||||
sequence: cell_string(row.get(sequence_idx)),
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
fn cell_string(cell: Option<&Data>) -> String {
|
||||
match cell {
|
||||
Some(value) => value.to_string().trim().to_owned(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_image_metadata(client: &Client, url: &str) -> Result<ImageMetadata, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|err| format!("request failed: {}", err))?;
|
||||
let response = response
|
||||
.error_for_status()
|
||||
.map_err(|err| format!("HTTP error: {}", err))?;
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.map_err(|err| format!("failed to read response body: {}", err))?;
|
||||
|
||||
let image = image::load_from_memory(&bytes)
|
||||
.map_err(|err| format!("unable to decode image bytes: {}", err))?;
|
||||
let (width, height) = image.dimensions();
|
||||
let size_kb = bytes.len() as f64 / 1024.0;
|
||||
|
||||
Ok(ImageMetadata {
|
||||
width,
|
||||
height,
|
||||
size_kb,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_output_path(input_path: &Path) -> PathBuf {
|
||||
input_path.with_file_name(format!("result_{}.xlsx", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn write_results_excel(path: &Path, rows: &[OutputRow]) -> Result<(), String> {
|
||||
let mut workbook = Workbook::new();
|
||||
let worksheet = workbook.add_worksheet();
|
||||
|
||||
worksheet
|
||||
.write_string(0, 0, "Cikkszám")
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(0, 1, "Sqeuence")
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(0, 2, "Url")
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(0, 3, "Width (px)")
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(0, 4, "Height (px)")
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(0, 5, "Size (KB)")
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let output_row = (index + 1) as u32;
|
||||
worksheet
|
||||
.write_string(output_row, 0, &row.cikkszam)
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(output_row, 1, &row.sequence)
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_string(output_row, 2, &row.url)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if let Some(metadata) = row.metadata {
|
||||
worksheet
|
||||
.write_number(output_row, 3, metadata.width as f64)
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_number(output_row, 4, metadata.height as f64)
|
||||
.map_err(|err| err.to_string())?;
|
||||
worksheet
|
||||
.write_number(output_row, 5, metadata.size_kb)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
workbook.save(path).map_err(|err| err.to_string())
|
||||
}
|
||||
Loading…
Reference in New Issue