1use crate::constants::RAWG_RATE_LIMIT_MS;
7use crate::database::{self, AppState};
8use crate::errors::AppError;
9use crate::models::WishlistGame;
10use crate::services::{itad, rawg};
11use chrono::NaiveDate;
12use rusqlite::{params, Connection};
13use serde::Deserialize;
14use std::fs;
15use std::time::Duration;
16use tauri::{AppHandle, Emitter, Manager, State};
17use tokio::time::sleep;
18use tracing::{error, info};
19
20#[derive(serde::Serialize)]
22pub struct SearchResult {
23 pub id: String,
24 pub name: String,
25 pub cover_url: Option<String>,
26}
27
28fn insert_game_internal(conn: &Connection, game: &WishlistGame) -> Result<(), AppError> {
33 conn.execute(
34 "INSERT OR REPLACE INTO wishlist (
35 id, name, cover_url, store_url, store_platform,
36 current_price, normal_price, lowest_price,
37 currency, on_sale, voucher, itad_id, added_at
38 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
39 params![
40 game.id,
41 game.name,
42 game.cover_url,
43 game.store_url,
44 game.store_platform,
45 game.current_price,
46 game.normal_price,
47 game.lowest_price,
48 game.currency,
49 game.on_sale,
50 game.voucher,
51 game.itad_id,
52 game.added_at
53 ],
54 )?;
55 Ok(())
56}
57
58#[derive(Deserialize)]
61struct SteamExportRoot {
62 data: Vec<SteamExportItem>,
63}
64
65#[derive(Deserialize)]
66struct SteamExportItem {
67 title: String,
68 gameid: Vec<String>, price: Option<String>, added_date: Option<String>, }
72
73#[derive(Deserialize)]
74struct ItadExportRoot {
75 data: ItadDataWrapper,
76}
77
78#[derive(Deserialize)]
79struct ItadDataWrapper {
80 data: Vec<ItadGroup>,
81}
82
83#[derive(Deserialize)]
84struct ItadGroup {
85 games: Vec<ItadGame>,
86}
87
88#[derive(Deserialize)]
89struct ItadGame {
90 id: String, title: String,
92 added: i64, }
94
95fn parse_steam_price(price_str: Option<&String>) -> Option<f64> {
96 price_str.as_ref().and_then(|s| {
98 let clean = s.replace("R$", "").replace(' ', "").replace(',', ".");
99 clean.parse::<f64>().ok()
100 })
101}
102
103fn parse_steam_date(date_str: Option<&String>) -> String {
104 if let Some(s) = date_str {
105 if let Ok(date) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
107 if let Some(datetime) = date.and_hms_opt(0, 0, 0) {
108 return datetime.and_utc().to_rfc3339();
109 }
110 }
111 }
112 chrono::Utc::now().to_rfc3339()
113}
114
115fn parse_steam_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
117 let export: SteamExportRoot = serde_json::from_str(content).ok()?;
118 let mut games = Vec::new();
119
120 for item in export.data {
121 let app_id = item
123 .gameid
124 .get(1)
125 .and_then(|s| s.strip_prefix("app/"))
126 .unwrap_or("0")
127 .to_string();
128
129 let price = parse_steam_price(item.price.as_ref());
130
131 let cover_url = format!(
133 "https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg",
134 app_id
135 );
136
137 games.push(WishlistGame {
138 id: app_id.clone(),
139 name: item.title,
140 cover_url: Some(cover_url),
141 store_url: Some(format!("https://store.steampowered.com/app/{}", app_id)),
142 store_platform: Some("Steam".to_string()),
143 itad_id: None,
144 current_price: price,
145 normal_price: price,
146 lowest_price: price,
147 currency: Some("BRL".to_string()),
148 on_sale: false,
149 voucher: None,
150 added_at: Some(parse_steam_date(item.added_date.as_ref())),
151 });
152 }
153 Some(games)
154}
155
156fn parse_itad_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
158 let export: ItadExportRoot = serde_json::from_str(content).ok()?;
159 let mut games = Vec::new();
160
161 for group in export.data.data {
162 for item in group.games {
163 let added_at = chrono::DateTime::from_timestamp(item.added, 0)
165 .map(|dt| dt.to_rfc3339())
166 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
167
168 games.push(WishlistGame {
169 id: item.id, name: item.title,
171 cover_url: None, store_url: None,
173 store_platform: Some("ITAD".to_string()),
174 itad_id: None,
175 current_price: None,
176 normal_price: None,
177 lowest_price: None,
178 currency: Some("BRL".to_string()),
179 on_sale: false,
180 voucher: None,
181 added_at: Some(added_at),
182 });
183 }
184 }
185 Some(games)
186}
187
188#[tauri::command]
190pub async fn import_wishlist(
191 state: State<'_, AppState>,
192 file_path: String,
193) -> Result<usize, AppError> {
194 let content = fs::read_to_string(&file_path)?;
196
197 let games = if let Some(steam_games) = parse_steam_wishlist(&content) {
199 steam_games
200 } else if let Some(itad_games) = parse_itad_wishlist(&content) {
201 itad_games
202 } else {
203 return Err(AppError::ValidationError(
204 "Formato de arquivo não reconhecido.".to_string(),
205 ));
206 };
207
208 let total = games.len();
209 if total == 0 {
210 return Ok(0);
211 }
212
213 {
215 let mut conn = state.library_db.lock()?;
216 let tx = conn.transaction()?;
217
218 for game in games {
219 insert_game_internal(&tx, &game)?;
220 }
221 tx.commit()?;
222 }
223
224 Ok(total)
225}
226
227#[tauri::command]
229pub async fn fetch_wishlist_covers(app: AppHandle) -> Result<(), AppError> {
230 let api_key = database::get_secret(&app, "rawg_api_key")?;
232 if api_key.is_empty() {
233 return Err(AppError::ValidationError(
234 "API Key da RAWG não configurada.".to_string(),
235 ));
236 }
237
238 tauri::async_runtime::spawn(async move {
240 let state: State<AppState> = app.state();
241
242 let missing_covers: Vec<(String, String)> = {
244 let conn = state.library_db.lock().unwrap();
245 let mut stmt = conn
246 .prepare("SELECT id, name FROM wishlist WHERE cover_url IS NULL OR cover_url = ''")
247 .unwrap();
248
249 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
250 .unwrap()
251 .flatten()
252 .collect()
253 };
254
255 if missing_covers.is_empty() {
256 return;
257 }
258
259 let mut updated_count = 0;
260
261 for (id, name) in missing_covers {
263 match rawg::search_games(&api_key, &name).await {
264 Ok(results) => {
265 if let Some(first_match) = results.iter().find(|g| g.background_image.is_some())
267 {
268 if let Some(cover) = &first_match.background_image {
269 if let Ok(conn) = state.library_db.lock() {
270 if conn
271 .execute(
272 "UPDATE wishlist SET cover_url = ?1 WHERE id = ?2",
273 params![cover, id],
274 )
275 .is_ok()
276 {
277 updated_count += 1;
278 }
279 }
280 }
281 }
282 }
283 Err(e) => error!("Erro RAWG para '{}': {}", name, e),
284 }
285
286 sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
288 }
289
290 if updated_count > 0 {
292 info!("{} capas atualizadas", updated_count);
293 }
294 let _ = app.emit("wishlist_updated", ());
295 });
296
297 Ok(())
298}
299
300#[tauri::command]
304pub async fn search_wishlist_game(
305 app: AppHandle,
306 query: String,
307) -> Result<Vec<SearchResult>, AppError> {
308 let api_key = database::get_secret(&app, "rawg_api_key")?;
310 if api_key.is_empty() {
311 return Err(AppError::ValidationError(
312 "Configure a chave da RAWG nas configurações.".to_string(),
313 ));
314 }
315
316 let results = rawg::search_games(&api_key, &query)
317 .await
318 .map_err(AppError::NetworkError)?;
319
320 Ok(results
321 .into_iter()
322 .map(|g| SearchResult {
323 id: g.id.to_string(),
324 name: g.name,
325 cover_url: g.background_image,
326 })
327 .collect())
328}
329
330#[tauri::command]
332pub fn add_to_wishlist(
333 state: State<AppState>,
334 id: String,
335 name: String,
336 cover_url: Option<String>,
337 store_url: Option<String>,
338 current_price: Option<f64>,
339 itad_id: Option<String>,
340) -> Result<String, AppError> {
341 let game = WishlistGame {
342 id,
343 name,
344 cover_url,
345 store_url,
346 store_platform: None,
347 itad_id,
348 current_price,
349 normal_price: current_price,
350 lowest_price: current_price,
351 currency: Some("BRL".to_string()),
352 on_sale: false,
353 voucher: None,
354 added_at: Some(chrono::Utc::now().to_rfc3339()),
355 };
356
357 let conn = state.library_db.lock()?;
358
359 insert_game_internal(&conn, &game)?;
360
361 Ok("Adicionado à Wishlist!".to_string())
362}
363
364#[tauri::command]
366pub fn remove_from_wishlist(state: State<AppState>, id: String) -> Result<String, AppError> {
367 let conn = state.library_db.lock()?;
368
369 conn.execute("DELETE FROM wishlist WHERE id = ?1", params![id])?;
370
371 Ok("Jogo removido da lista de desejos.".to_string())
372}
373
374#[tauri::command]
376pub fn get_wishlist(state: State<AppState>) -> Result<Vec<WishlistGame>, AppError> {
377 let conn = state.library_db.lock()?;
378
379 let mut stmt = conn
380 .prepare("SELECT id, name, cover_url, store_url, store_platform, current_price, normal_price, lowest_price, currency, on_sale, voucher, added_at, itad_id FROM wishlist ORDER BY added_at DESC")?;
381
382 let games = stmt
383 .query_map([], |row| {
384 Ok(WishlistGame {
385 id: row.get(0)?,
386 name: row.get(1)?,
387 cover_url: row.get(2)?,
388 store_url: row.get(3)?,
389 store_platform: row.get(4)?,
390 current_price: row.get(5)?,
391 normal_price: row.get(6)?,
392 lowest_price: row.get(7)?,
393 currency: row.get(8)?,
394 on_sale: row.get(9)?,
395 voucher: row.get(10)?,
396 added_at: row.get(11)?,
397 itad_id: row.get(12)?,
398 })
399 })?
400 .collect::<Result<Vec<_>, _>>()?;
401
402 Ok(games)
403}
404
405#[tauri::command]
407pub fn check_wishlist_status(state: State<AppState>, id: String) -> Result<bool, AppError> {
408 let conn = state.library_db.lock()?;
409
410 let count: i32 = conn
411 .query_row(
412 "SELECT COUNT(1) FROM wishlist WHERE id = ?1",
413 params![id],
414 |row| row.get(0),
415 )
416 .unwrap_or(0);
417
418 Ok(count > 0)
419}
420
421#[tauri::command]
423pub async fn refresh_prices(
424 _app: AppHandle,
425 state: State<'_, AppState>,
426) -> Result<String, AppError> {
427 let games_to_check: Vec<(String, String, Option<String>)> = {
429 let conn = state.library_db.lock()?;
430 let mut stmt = conn.prepare("SELECT id, name, itad_id FROM wishlist")?;
431 let rows = stmt.query_map([], |row| {
432 Ok((
433 row.get::<_, String>(0)?,
434 row.get::<_, String>(1)?,
435 row.get::<_, Option<String>>(2)?,
436 ))
437 })?;
438
439 rows.filter_map(|r| r.ok()).collect()
440 };
441
442 if games_to_check.is_empty() {
443 return Ok("Lista de desejos vazia.".to_string());
444 }
445
446 let mut itad_ids_to_fetch = Vec::new();
448 let mut game_map = std::collections::HashMap::new(); for (local_id, name, current_itad_id) in games_to_check {
451 let final_itad_id = match current_itad_id {
452 Some(id) if !id.is_empty() => {
453 id }
455 _ => {
456 match itad::find_game_id(&name).await {
458 Ok(found_id) => {
459 let conn = state.library_db.lock()?;
461 let _ = conn.execute(
462 "UPDATE wishlist SET itad_id = ?1 WHERE id = ?2",
463 params![&found_id, &local_id],
464 );
465 found_id
466 }
467 Err(e) => {
468 error!("Jogo '{}' não encontrado na ITAD: {}", name, e);
469 continue; }
471 }
472 }
473 };
474 itad_ids_to_fetch.push(final_itad_id.clone());
475 game_map.insert(final_itad_id, (local_id, name));
476 }
477
478 if itad_ids_to_fetch.is_empty() {
480 return Ok("Nenhum jogo correspondente encontrado na ITAD.".to_string());
481 }
482
483 let overviews = itad::get_prices(itad_ids_to_fetch)
484 .await
485 .map_err(AppError::NetworkError)?;
486
487 let mut updated_count = 0;
488
489 let conn = state.library_db.lock()?;
491
492 for game_data in overviews {
493 if let Some((local_id, _game_name)) = game_map.get(&game_data.id) {
494 if let Some(deal) = game_data.current {
496 let lowest = game_data.lowest.map(|l| l.price).unwrap_or(deal.price);
497
498 let cut = deal.cut.unwrap_or(0) as f64;
499 let normal_price = if cut > 0.0 {
500 deal.price / (1.0 - (cut / 100.0))
501 } else {
502 deal.price
503 };
504 match conn.execute(
505 "UPDATE wishlist SET
506 current_price = ?1,
507 currency = ?2,
508 lowest_price = ?3,
509 store_platform = ?4,
510 store_url = ?5,
511 on_sale = ?6,
512 normal_price = ?7,
513 voucher = ?8
514 WHERE id = ?9",
515 params![
516 deal.price,
517 deal.currency,
518 lowest,
519 deal.shop.name,
520 deal.url,
521 deal.cut > Some(0),
522 normal_price,
523 deal.voucher,
524 local_id
525 ],
526 ) {
527 Ok(_) => {
528 updated_count += 1;
529 }
530 Err(e) => error!("Erro ao salvar preço: {}", e),
531 }
532 }
533 } else {
534 error!("ITAD ID {} não encontrado no mapa local", game_data.id);
535 }
536 }
537
538 if updated_count > 0 {
539 info!("{} preços atualizados", updated_count);
540 }
541
542 Ok(format!("{} preços atualizados", updated_count))
543}