Implemented CSV and Excel parsing logic in src/main.rs
This commit is contained in:
1123
Cargo.lock
generated
1123
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -1,17 +1,19 @@
|
||||
[package]
|
||||
name = "ae_anons"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0.18"
|
||||
urlencoding = "2.1.3"
|
||||
tokio = { version = "1.51.1", features = ["full"] }
|
||||
csv = "1.4.0"
|
||||
dotenv = "0.15.0"
|
||||
reqwest = { version = "0.13", features = ["json", "multipart", "stream"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
urlencoding = "2.1"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
csv = "1.3"
|
||||
dotenv = "0.15"
|
||||
calamine = "0.26"
|
||||
chrono = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.51.1", features = ["full", "rt-multi-thread"] }
|
||||
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
||||
32
src/lib.rs
32
src/lib.rs
@@ -1,9 +1,9 @@
|
||||
use reqwest::{
|
||||
multipart::{Form, Part},
|
||||
Client, ClientBuilder,
|
||||
multipart::{Form, Part},
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
@@ -1173,15 +1173,16 @@ impl SynologyClient {
|
||||
|
||||
/// Скачивание файла (возвращает бинарные данные)
|
||||
pub async fn download(&self, path: &str) -> Result<Vec<u8>> {
|
||||
let url = format!(
|
||||
let mut url = format!(
|
||||
"{}/webapi/entry.cgi?api=SYNO.FileStation.Download&version=2&method=download&path={}&mode=open",
|
||||
self.base_url, path
|
||||
);
|
||||
let mut request_builder = self.client.get(&url);
|
||||
|
||||
if let Some(sid) = &self.sid {
|
||||
request_builder = request_builder.query(&[("_sid", sid)]);
|
||||
url = format!("{}&_sid={}", url, sid);
|
||||
}
|
||||
let response = request_builder.send().await?;
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await?;
|
||||
@@ -1203,16 +1204,16 @@ impl SynologyClient {
|
||||
overwrite: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let mut form = Form::new()
|
||||
.text("api".to_string(), "SYNO.FileStation.Upload".to_string())
|
||||
.text("version".to_string(), "2".to_string())
|
||||
.text("method".to_string(), "upload".to_string())
|
||||
.text("path".to_string(), folder_path.to_string());
|
||||
.text("api", "SYNO.FileStation.Upload".to_string())
|
||||
.text("version", "2".to_string())
|
||||
.text("method", "upload".to_string())
|
||||
.text("path", folder_path.to_string());
|
||||
|
||||
if let Some(create_parents) = create_parents {
|
||||
form = form.text("create_parents".to_string(), create_parents.to_string());
|
||||
form = form.text("create_parents", create_parents.to_string());
|
||||
}
|
||||
if let Some(overwrite) = overwrite {
|
||||
form = form.text("overwrite".to_string(), overwrite.to_string());
|
||||
form = form.text("overwrite", overwrite.to_string());
|
||||
}
|
||||
|
||||
let file_part = Part::bytes(data)
|
||||
@@ -1221,14 +1222,13 @@ impl SynologyClient {
|
||||
|
||||
form = form.part("file", file_part);
|
||||
|
||||
let url = format!("{}/webapi/entry.cgi", self.base_url);
|
||||
let mut request_builder = self.client.post(&url).multipart(form);
|
||||
let mut url = format!("{}/webapi/entry.cgi", self.base_url);
|
||||
|
||||
if let Some(sid) = &self.sid {
|
||||
request_builder = request_builder.query(&[("_sid", sid)]);
|
||||
url = format!("{}?_sid={}", url, sid);
|
||||
}
|
||||
|
||||
let response = request_builder.send().await?;
|
||||
let response = self.client.post(&url).multipart(form).send().await?;
|
||||
let api_response: ApiResponse<EmptyData> = response.json().await?;
|
||||
|
||||
if api_response.success {
|
||||
|
||||
389
src/main.rs
389
src/main.rs
@@ -1,12 +1,101 @@
|
||||
use calamine::{Data, Reader, Xlsx};
|
||||
use chrono::{Duration, NaiveDate};
|
||||
use dotenv::dotenv;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use serde_json::Value;
|
||||
|
||||
use ae_anons::{SynologyClient, SynologyError};
|
||||
|
||||
/// Универсальная структура для хранения данных любого листа
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SheetData {
|
||||
pub name: String,
|
||||
pub headers: Vec<String>,
|
||||
pub rows: Vec<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SheetData {
|
||||
/// Получить значение по индексу строки и имени колонки
|
||||
pub fn get(&self, row_idx: usize, col_name: &str) -> Option<&String> {
|
||||
self.rows.get(row_idx).and_then(|row| row.get(col_name))
|
||||
}
|
||||
|
||||
/// Получить все значения колонки
|
||||
pub fn get_column(&self, col_name: &str) -> Vec<Option<&String>> {
|
||||
self.rows.iter().map(|row| row.get(col_name)).collect()
|
||||
}
|
||||
|
||||
/// Получить строку как JSON
|
||||
pub fn row_to_json(&self, row_idx: usize) -> Option<Value> {
|
||||
self.rows.get(row_idx).map(|row| {
|
||||
let map: serde_json::Map<String, Value> = row
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect();
|
||||
Value::Object(map)
|
||||
})
|
||||
}
|
||||
|
||||
/// Конвертировать весь лист в JSON
|
||||
pub fn to_json(&self) -> Value {
|
||||
let rows_json: Vec<Value> = self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let map: serde_json::Map<String, Value> = row
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect();
|
||||
Value::Object(map)
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"name": self.name,
|
||||
"headers": self.headers,
|
||||
"rows": rows_json,
|
||||
"row_count": self.rows.len(),
|
||||
"column_count": self.headers.len()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Структура для хранения всех листов Excel
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExcelWorkbook {
|
||||
pub sheets: Vec<SheetData>,
|
||||
}
|
||||
|
||||
impl ExcelWorkbook {
|
||||
/// Получить лист по имени
|
||||
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
|
||||
self.sheets.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// Получить лист по индексу
|
||||
pub fn get_sheet_by_index(&self, index: usize) -> Option<&SheetData> {
|
||||
self.sheets.get(index)
|
||||
}
|
||||
|
||||
/// Конвертировать всю книгу в JSON
|
||||
pub fn to_json(&self) -> Value {
|
||||
let sheets_json: Vec<Value> = self.sheets.iter().map(|s| s.to_json()).collect();
|
||||
serde_json::json!(sheets_json)
|
||||
}
|
||||
|
||||
/// Получить все заголовки всех листов
|
||||
pub fn get_all_headers(&self) -> HashMap<String, Vec<String>> {
|
||||
self.sheets
|
||||
.iter()
|
||||
.map(|sheet| (sheet.name.clone(), sheet.headers.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), SynologyError> {
|
||||
dotenv().ok();
|
||||
@@ -24,27 +113,30 @@ async fn main() -> Result<(), SynologyError> {
|
||||
let info = client.get_info().await?;
|
||||
println!("📡 Connected to: {}", info.hostname);
|
||||
|
||||
// Получаем имя файла и ожидаемый путь
|
||||
let file_name_full = Path::new(&nas_file).file_name().unwrap().to_str().unwrap();
|
||||
// Получаем имя файла
|
||||
let file_name_full = Path::new(&nas_file)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let search_name = file_name_full.replace(".osheet", "");
|
||||
let expected_path = Path::new(&nas_file).parent().unwrap().to_str().unwrap();
|
||||
let expected_path = Path::new(&nas_file)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Преобразуем путь в формат API (Team Folder -> team-folders, убираем пробелы, приводим к нижнему регистру)
|
||||
let api_format_path = expected_path
|
||||
.replace("Team Folder", "team-folders")
|
||||
.replace(" ", "");
|
||||
.replace(' ', "");
|
||||
|
||||
let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full);
|
||||
|
||||
println!("\n🔍 Looking for exact file: {}", full_expected_path_api);
|
||||
println!(" (converted from: {})", nas_file);
|
||||
println!("\n🔍 Looking for file: {}", full_expected_path_api);
|
||||
|
||||
// Ищем файл по имени
|
||||
// Ищем файл
|
||||
let search_result = client.search_file_by_name(&search_name).await?;
|
||||
|
||||
// Извлекаем все найденные файлы
|
||||
let items = search_result["data"]["items"].as_array()
|
||||
.ok_or_else(|| SynologyError::InvalidResponseFormat("No items in search result".to_string()))?;
|
||||
let items = search_result["data"]["items"].as_array().ok_or_else(|| {
|
||||
SynologyError::InvalidResponseFormat("No items in search result".to_string())
|
||||
})?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(SynologyError::Api {
|
||||
@@ -53,81 +145,266 @@ async fn main() -> Result<(), SynologyError> {
|
||||
});
|
||||
}
|
||||
|
||||
// Ищем файл с точным совпадением пути (в формате API)
|
||||
let exact_match: Vec<&Value> = items.iter()
|
||||
let exact_match: Vec<&Value> = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let display_path = item.get("display_path")
|
||||
item.get("display_path")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("");
|
||||
display_path == full_expected_path_api
|
||||
.unwrap_or("")
|
||||
== full_expected_path_api
|
||||
})
|
||||
.collect();
|
||||
|
||||
if exact_match.is_empty() {
|
||||
println!("\n⚠️ File not found at exact path: {}", full_expected_path_api);
|
||||
println!(" Found {} file(s) with similar name:", items.len());
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let name = item.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let display_path = item.get("display_path")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let file_id = item.get("file_id")
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("unknown");
|
||||
println!(" {}. {} (at: {}, ID: {})", i + 1, name, display_path, file_id);
|
||||
}
|
||||
|
||||
return Err(SynologyError::Api {
|
||||
code: 400,
|
||||
message: Some(format!("File not found at: {}", full_expected_path_api)),
|
||||
});
|
||||
}
|
||||
|
||||
if exact_match.len() > 1 {
|
||||
println!("\n⚠️ Found {} files at the same location!", exact_match.len());
|
||||
return Err(SynologyError::Api {
|
||||
code: 400,
|
||||
message: Some("Multiple files found at the same location".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Берём единственный подходящий файл
|
||||
let file = exact_match[0];
|
||||
let file_id = file["file_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SynologyError::InvalidResponseFormat("Missing file_id".to_string()))?;
|
||||
|
||||
let actual_file_name = file["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| SynologyError::InvalidResponseFormat("Missing name".to_string()))?;
|
||||
|
||||
let actual_path = file["display_path"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown");
|
||||
|
||||
println!(" 📄 Found exact match: {} at {}", actual_file_name, actual_path);
|
||||
println!(" 📄 File ID: {}", file_id);
|
||||
println!(" 📄 Found: {}", actual_file_name);
|
||||
|
||||
// Экспортируем файл
|
||||
println!("\n📊 Exporting file...");
|
||||
|
||||
match client.export_by_file_id(file_id, actual_file_name).await {
|
||||
Ok(data) => {
|
||||
println!("\n📥 Exporting file...");
|
||||
let output_path = format!("./{}_export.xlsx", search_name);
|
||||
|
||||
let data = client.export_by_file_id(file_id, actual_file_name).await?;
|
||||
let mut file = File::create(&output_path)?;
|
||||
file.write_all(&data)?;
|
||||
println!(" ✅ Exported to: {} ({} bytes)", output_path, data.len());
|
||||
|
||||
// Читаем Excel и получаем структурированные данные
|
||||
println!("\n📖 Parsing Excel file...");
|
||||
let workbook = read_excel_workbook(&output_path)?;
|
||||
|
||||
// Выводим информацию о структуре
|
||||
println!("\n📊 Excel Structure:");
|
||||
println!(" Total sheets: {}", workbook.sheets.len());
|
||||
|
||||
for (idx, sheet) in workbook.sheets.iter().enumerate() {
|
||||
println!("\n Sheet #{}: '{}'", idx + 1, sheet.name);
|
||||
println!(
|
||||
" Headers ({}): {:?}",
|
||||
sheet.headers.len(),
|
||||
sheet.headers
|
||||
);
|
||||
println!(" Rows: {}", sheet.rows.len());
|
||||
|
||||
// Показываем пример данных из первой строки
|
||||
if let Some(first_row) = sheet.rows.first() {
|
||||
println!(" First row sample:");
|
||||
for (key, value) in first_row.iter().take(5) {
|
||||
println!(" {} = {}", key, value);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ❌ Export failed: {}", e);
|
||||
if first_row.len() > 5 {
|
||||
println!(" ... and {} more fields", first_row.len() - 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем всю книгу в JSON
|
||||
let json_output = format!("./{}_workbook.json", search_name);
|
||||
let json_data = workbook.to_json();
|
||||
let json_str = serde_json::to_string_pretty(&json_data)
|
||||
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
|
||||
std::fs::write(&json_output, json_str)?;
|
||||
println!("\n💾 Full workbook saved to: {}", json_output);
|
||||
|
||||
// Сохраняем каждый лист отдельно
|
||||
for sheet in &workbook.sheets {
|
||||
let safe_name = sheet
|
||||
.name
|
||||
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
|
||||
let sheet_json_path = format!("./{}_{}.json", search_name, safe_name);
|
||||
let sheet_json = sheet.to_json();
|
||||
let sheet_json_str = serde_json::to_string_pretty(&sheet_json)
|
||||
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
|
||||
std::fs::write(&sheet_json_path, sheet_json_str)?;
|
||||
println!("💾 Sheet '{}' saved to: {}", sheet.name, sheet_json_path);
|
||||
}
|
||||
|
||||
client.logout().await?;
|
||||
println!("\n👋 Logged out");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Читает Excel файл и возвращает структурированную книгу
|
||||
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError> {
|
||||
let mut workbook: Xlsx<_> = calamine::open_workbook(file_path)
|
||||
.map_err(|e| SynologyError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
|
||||
let sheet_names = workbook.sheet_names().to_vec();
|
||||
let mut excel_workbook = ExcelWorkbook::default();
|
||||
|
||||
for sheet_name in sheet_names {
|
||||
println!(" 📄 Processing sheet: '{}'", sheet_name);
|
||||
|
||||
let range = match workbook.worksheet_range(&sheet_name) {
|
||||
Ok(range) => range,
|
||||
Err(e) => {
|
||||
println!(" ⚠️ Error reading sheet: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let sheet_data = parse_sheet_dynamic(&sheet_name, &range)?;
|
||||
excel_workbook.sheets.push(sheet_data);
|
||||
}
|
||||
|
||||
Ok(excel_workbook)
|
||||
}
|
||||
|
||||
/// Парсит лист в динамическую структуру
|
||||
fn parse_sheet_dynamic(
|
||||
sheet_name: &str,
|
||||
range: &calamine::Range<Data>,
|
||||
) -> Result<SheetData, SynologyError> {
|
||||
// Собираем все ячейки в структуру
|
||||
let mut cell_map: HashMap<(usize, usize), String> = HashMap::new();
|
||||
let mut max_row = 0usize;
|
||||
let mut max_col = 0usize;
|
||||
|
||||
for (row, col, cell) in range.cells() {
|
||||
let value = cell_to_string(cell);
|
||||
if !value.is_empty() {
|
||||
cell_map.insert((row, col), value);
|
||||
max_row = max_row.max(row);
|
||||
max_col = max_col.max(col);
|
||||
}
|
||||
}
|
||||
|
||||
if cell_map.is_empty() {
|
||||
return Ok(SheetData {
|
||||
name: sheet_name.to_string(),
|
||||
headers: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Создаем матрицу данных
|
||||
let mut data_matrix: Vec<Vec<String>> = Vec::new();
|
||||
|
||||
for row in 0..=max_row {
|
||||
let mut row_data = Vec::new();
|
||||
for col in 0..=max_col {
|
||||
let value = cell_map.get(&(row, col)).cloned().unwrap_or_default();
|
||||
row_data.push(value);
|
||||
}
|
||||
data_matrix.push(row_data);
|
||||
}
|
||||
|
||||
// Первая строка - заголовки
|
||||
let headers: Vec<String> = if !data_matrix.is_empty() {
|
||||
data_matrix[0]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, header)| {
|
||||
let h = header.trim().to_string();
|
||||
if h.is_empty() {
|
||||
format!("Column_{}", idx + 1)
|
||||
} else {
|
||||
h
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
println!(" Found {} headers: {:?}", headers.len(), headers);
|
||||
|
||||
let mut rows_data = Vec::new();
|
||||
|
||||
// Остальные строки - данные
|
||||
for row_values in data_matrix.iter().skip(1) {
|
||||
let mut row_map = HashMap::new();
|
||||
|
||||
for (col_idx, value) in row_values.iter().enumerate() {
|
||||
if col_idx < headers.len() && !value.is_empty() {
|
||||
row_map.insert(headers[col_idx].clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !row_map.is_empty() {
|
||||
rows_data.push(row_map);
|
||||
}
|
||||
|
||||
if rows_data.len() >= 10000 {
|
||||
println!(" ... truncated at 10000 rows");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Loaded {} data rows", rows_data.len());
|
||||
|
||||
Ok(SheetData {
|
||||
name: sheet_name.to_string(),
|
||||
headers,
|
||||
rows: rows_data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Преобразует Excel серийный номер даты в строку формата DD.MM.YYYY
|
||||
fn excel_date_to_string(serial: f64) -> String {
|
||||
let days = serial as i64;
|
||||
// Excel использует 30.12.1899 как базовую дату
|
||||
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
||||
|
||||
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
||||
date.format("%d.%m.%Y").to_string()
|
||||
} else {
|
||||
serial.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, является ли число датой Excel
|
||||
fn is_excel_date(value: f64) -> bool {
|
||||
// Excel даты обычно в диапазоне от 1 до 100000
|
||||
value > 1.0 && value < 100000.0
|
||||
}
|
||||
|
||||
/// Преобразует ячейку в строку с конвертацией дат
|
||||
fn cell_to_string(cell: &Data) -> String {
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if is_excel_date(*f) {
|
||||
excel_date_to_string(*f)
|
||||
} else if f.fract() == 0.0 {
|
||||
format!("{:.0}", f)
|
||||
} else {
|
||||
f.to_string()
|
||||
}
|
||||
}
|
||||
Data::Int(i) => {
|
||||
let f = *i as f64;
|
||||
if is_excel_date(f) {
|
||||
excel_date_to_string(f)
|
||||
} else {
|
||||
i.to_string()
|
||||
}
|
||||
}
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => {
|
||||
let serial = dt.as_f64();
|
||||
if is_excel_date(serial) {
|
||||
excel_date_to_string(serial)
|
||||
} else {
|
||||
dt.to_string()
|
||||
}
|
||||
}
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(e) => format!("{:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user