1use crate::repository::*;
6use crate::types::*;
7use rusqlite::{params, Connection, OptionalExtension};
8use std::collections::HashMap;
9use std::path::Path;
10
11pub const DEFAULT_DB_PATH: &str = "share/items.db";
13
14pub struct SqliteDb {
16 conn: Connection,
17}
18
19const ITEM_SELECT_COLUMNS: &str = "serial, name, prefix, manufacturer, weapon_type, item_type, rarity, level, element,
20 dps, damage, accuracy, fire_rate, reload_time, mag_size, value, red_text,
21 notes, verification_status, verification_notes, verified_at, legal, source, created_at";
22
23fn row_to_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<Item> {
24 let status_str: String = row
25 .get::<_, Option<String>>(18)?
26 .unwrap_or_else(|| "unverified".to_string());
27 Ok(Item {
28 serial: row.get(0)?,
29 name: row.get(1)?,
30 prefix: row.get(2)?,
31 manufacturer: row.get(3)?,
32 weapon_type: row.get(4)?,
33 item_type: row.get(5)?,
34 rarity: row.get(6)?,
35 level: row.get(7)?,
36 element: row.get(8)?,
37 dps: row.get(9)?,
38 damage: row.get(10)?,
39 accuracy: row.get(11)?,
40 fire_rate: row.get(12)?,
41 reload_time: row.get(13)?,
42 mag_size: row.get(14)?,
43 value: row.get(15)?,
44 red_text: row.get(16)?,
45 notes: row.get(17)?,
46 verification_status: status_str.parse().unwrap_or(VerificationStatus::Unverified),
47 verification_notes: row.get(19)?,
48 verified_at: row.get(20)?,
49 legal: row.get::<_, Option<bool>>(21)?.unwrap_or(false),
50 source: row.get(22)?,
51 created_at: row.get::<_, Option<String>>(23)?.unwrap_or_default(),
52 })
53}
54
55fn build_list_query(filter: &ItemFilter) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
56 let mut sql = format!("SELECT {} FROM items WHERE 1=1", ITEM_SELECT_COLUMNS);
57 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
58
59 if let Some(m) = &filter.manufacturer {
60 sql.push_str(" AND manufacturer = ?");
61 params.push(Box::new(m.clone()));
62 }
63 if let Some(w) = &filter.weapon_type {
64 sql.push_str(" AND weapon_type = ?");
65 params.push(Box::new(w.clone()));
66 }
67 if let Some(e) = &filter.element {
68 sql.push_str(" AND element = ?");
69 params.push(Box::new(e.clone()));
70 }
71 if let Some(r) = &filter.rarity {
72 sql.push_str(" AND rarity = ?");
73 params.push(Box::new(r.clone()));
74 }
75
76 sql.push_str(" ORDER BY created_at DESC");
77 if let Some(limit) = filter.limit {
78 sql.push_str(&format!(" LIMIT {}", limit));
79 }
80 if let Some(offset) = filter.offset {
81 sql.push_str(&format!(" OFFSET {}", offset));
82 }
83
84 (sql, params)
85}
86
87impl SqliteDb {
88 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, rusqlite::Error> {
90 let conn = Connection::open(path.as_ref())?;
91 Ok(Self { conn })
92 }
93
94 pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
96 let conn = Connection::open_in_memory()?;
97 Ok(Self { conn })
98 }
99
100 #[allow(clippy::too_many_lines)] fn migrate_to_serial_pk(&self) -> RepoResult<()> {
103 println!("Migrating database to use serial as primary key...");
104
105 self.conn
106 .execute_batch(
107 r#"
108 ALTER TABLE weapons RENAME TO weapons_old;
109 ALTER TABLE weapon_parts RENAME TO weapon_parts_old;
110 ALTER TABLE attachments RENAME TO attachments_old;
111
112 CREATE TABLE weapons (
113 serial TEXT PRIMARY KEY NOT NULL,
114 name TEXT,
115 prefix TEXT,
116 manufacturer TEXT,
117 weapon_type TEXT,
118 item_type TEXT,
119 rarity TEXT,
120 level INTEGER,
121 element TEXT,
122 dps INTEGER,
123 damage INTEGER,
124 accuracy INTEGER,
125 fire_rate REAL,
126 reload_time REAL,
127 mag_size INTEGER,
128 value INTEGER,
129 red_text TEXT,
130 notes TEXT,
131 verification_status TEXT DEFAULT 'unverified',
132 verification_notes TEXT,
133 verified_at TIMESTAMP,
134 legal BOOLEAN DEFAULT FALSE,
135 source TEXT,
136 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
137 );
138
139 CREATE TABLE weapon_parts (
140 id INTEGER PRIMARY KEY AUTOINCREMENT,
141 item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
142 slot TEXT NOT NULL,
143 part_index INTEGER,
144 part_name TEXT,
145 manufacturer TEXT,
146 effect TEXT,
147 verified BOOLEAN DEFAULT FALSE,
148 verification_method TEXT,
149 verification_notes TEXT,
150 verified_at TIMESTAMP
151 );
152
153 CREATE TABLE attachments (
154 id INTEGER PRIMARY KEY AUTOINCREMENT,
155 item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
156 name TEXT NOT NULL,
157 mime_type TEXT NOT NULL,
158 data BLOB NOT NULL,
159 view TEXT DEFAULT 'OTHER'
160 );
161
162 INSERT INTO weapons (serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
163 level, element, dps, damage, accuracy, fire_rate, reload_time, mag_size, value,
164 red_text, notes, verification_status, verification_notes, verified_at, legal,
165 source, created_at)
166 SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
167 level, element, dps, damage, accuracy, fire_rate, reload_time, mag_size, value,
168 red_text, notes, verification_status, verification_notes, verified_at, legal,
169 source, created_at
170 FROM weapons_old;
171
172 INSERT INTO weapon_parts (item_serial, slot, part_index, part_name, manufacturer,
173 effect, verified, verification_method, verification_notes, verified_at)
174 SELECT w.serial, wp.slot, wp.part_index, wp.part_name, wp.manufacturer,
175 wp.effect, wp.verified, wp.verification_method, wp.verification_notes, wp.verified_at
176 FROM weapon_parts_old wp
177 JOIN weapons_old w ON wp.weapon_id = w.id;
178
179 INSERT INTO attachments (item_serial, name, mime_type, data)
180 SELECT w.serial, a.name, a.mime_type, a.data
181 FROM attachments_old a
182 JOIN weapons_old w ON a.weapon_id = w.id;
183
184 DROP TABLE attachments_old;
185 DROP TABLE weapon_parts_old;
186 DROP TABLE weapons_old;
187
188 DROP INDEX IF EXISTS idx_weapons_serial;
189 DROP INDEX IF EXISTS idx_weapon_parts_weapon_id;
190 DROP INDEX IF EXISTS idx_attachments_weapon_id;
191 "#,
192 )
193 .map_err(|e| RepoError::Database(e.to_string()))?;
194
195 println!("Migration complete.");
196 Ok(())
197 }
198
199 pub fn get_setting(&self, key: &str) -> RepoResult<Option<String>> {
201 let result = self.conn.query_row(
202 "SELECT value FROM settings WHERE key = ?1",
203 params![key],
204 |row| row.get(0),
205 );
206 match result {
207 Ok(value) => Ok(Some(value)),
208 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
209 Err(e) => Err(RepoError::Database(e.to_string())),
210 }
211 }
212
213 pub fn set_setting(&self, key: &str, value: &str) -> RepoResult<()> {
215 self.conn
216 .execute(
217 "INSERT INTO settings (key, value) VALUES (?1, ?2)
218 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
219 params![key, value],
220 )
221 .map_err(|e| RepoError::Database(e.to_string()))?;
222 Ok(())
223 }
224
225 pub fn get_or_create_salt(&self) -> RepoResult<String> {
227 if let Some(salt) = self.get_setting("source_salt")? {
228 Ok(salt)
229 } else {
230 let salt = crate::generate_salt();
231 self.set_setting("source_salt", &salt)?;
232 Ok(salt)
233 }
234 }
235
236 pub fn get_distinct_sources(&self) -> RepoResult<Vec<String>> {
238 let mut stmt = self
239 .conn
240 .prepare("SELECT DISTINCT source FROM items WHERE source IS NOT NULL")
241 .map_err(|e| RepoError::Database(e.to_string()))?;
242 let sources = stmt
243 .query_map([], |row| row.get(0))
244 .map_err(|e| RepoError::Database(e.to_string()))?
245 .collect::<Result<Vec<String>, _>>()
246 .map_err(|e| RepoError::Database(e.to_string()))?;
247 Ok(sources)
248 }
249}
250
251impl SqliteDb {
252 fn is_migration_applied(&self, version: &str) -> RepoResult<bool> {
254 let result: Option<i64> = self
255 .conn
256 .query_row(
257 "SELECT 1 FROM schema_migrations WHERE version = ?1",
258 params![version],
259 |row| row.get(0),
260 )
261 .optional()
262 .map_err(|e| RepoError::Database(e.to_string()))?;
263 Ok(result.is_some())
264 }
265
266 fn mark_migration_applied(&self, version: &str) -> RepoResult<()> {
268 self.conn
269 .execute(
270 "INSERT INTO schema_migrations (version) VALUES (?1)",
271 params![version],
272 )
273 .map_err(|e| RepoError::Database(e.to_string()))?;
274 Ok(())
275 }
276
277 #[allow(clippy::too_many_lines)] fn run_migrations(&self) -> RepoResult<()> {
280 let tables_exist = self
282 .conn
283 .query_row(
284 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapons'",
285 [],
286 |_| Ok(true),
287 )
288 .unwrap_or(false);
289
290 if !self.is_migration_applied("0001_base_schema")? {
292 self.conn
293 .execute_batch(
294 r#"
295 CREATE TABLE IF NOT EXISTS weapons (
296 serial TEXT PRIMARY KEY NOT NULL,
297 name TEXT,
298 prefix TEXT,
299 manufacturer TEXT,
300 weapon_type TEXT,
301 item_type TEXT,
302 rarity TEXT,
303 level INTEGER,
304 element TEXT,
305 dps INTEGER,
306 damage INTEGER,
307 accuracy INTEGER,
308 fire_rate REAL,
309 reload_time REAL,
310 mag_size INTEGER,
311 value INTEGER,
312 red_text TEXT,
313 notes TEXT,
314 verification_status TEXT DEFAULT 'unverified',
315 verification_notes TEXT,
316 verified_at TIMESTAMP,
317 legal BOOLEAN DEFAULT FALSE,
318 source TEXT,
319 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
320 );
321
322 CREATE TABLE IF NOT EXISTS weapon_parts (
323 id INTEGER PRIMARY KEY AUTOINCREMENT,
324 item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
325 slot TEXT NOT NULL,
326 part_index INTEGER,
327 part_name TEXT,
328 manufacturer TEXT,
329 effect TEXT,
330 verified BOOLEAN DEFAULT FALSE,
331 verification_method TEXT,
332 verification_notes TEXT,
333 verified_at TIMESTAMP
334 );
335
336 CREATE TABLE IF NOT EXISTS attachments (
337 id INTEGER PRIMARY KEY AUTOINCREMENT,
338 item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
339 name TEXT NOT NULL,
340 mime_type TEXT NOT NULL,
341 data BLOB NOT NULL,
342 view TEXT DEFAULT 'OTHER'
343 );
344
345 CREATE TABLE IF NOT EXISTS item_values (
346 id INTEGER PRIMARY KEY AUTOINCREMENT,
347 item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
348 field TEXT NOT NULL,
349 value TEXT NOT NULL,
350 source TEXT NOT NULL,
351 source_detail TEXT,
352 confidence TEXT NOT NULL DEFAULT 'inferred',
353 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
354 UNIQUE(item_serial, field, source)
355 );
356
357 CREATE TABLE IF NOT EXISTS settings (
358 key TEXT PRIMARY KEY NOT NULL,
359 value TEXT NOT NULL
360 );
361 "#,
362 )
363 .map_err(|e| RepoError::Database(e.to_string()))?;
364
365 self.mark_migration_applied("0001_base_schema")?;
366
367 if tables_exist {
368 println!("SQLite: Marked existing schema as migrated (0001_base_schema)");
369 } else {
370 println!("SQLite: Applied migration 0001_base_schema");
371 }
372 }
373
374 if !self.is_migration_applied("0002_rename_tables")? {
376 self.conn
377 .execute_batch(
378 r#"
379 ALTER TABLE weapons RENAME TO items;
380 ALTER TABLE weapon_parts RENAME TO item_parts;
381 "#,
382 )
383 .map_err(|e| RepoError::Database(e.to_string()))?;
384
385 self.mark_migration_applied("0002_rename_tables")?;
386 println!("SQLite: Applied migration 0002_rename_tables");
387 }
388
389 self.conn
391 .execute_batch(
392 r#"
393 CREATE INDEX IF NOT EXISTS idx_items_name ON items(name);
394 CREATE INDEX IF NOT EXISTS idx_items_manufacturer ON items(manufacturer);
395 CREATE INDEX IF NOT EXISTS idx_item_parts_item_serial ON item_parts(item_serial);
396 CREATE INDEX IF NOT EXISTS idx_item_values_serial ON item_values(item_serial);
397 CREATE INDEX IF NOT EXISTS idx_item_values_field ON item_values(item_serial, field);
398 CREATE INDEX IF NOT EXISTS idx_attachments_item_serial ON attachments(item_serial);
399 "#,
400 )
401 .map_err(|e| RepoError::Database(e.to_string()))?;
402
403 Ok(())
404 }
405}
406
407impl ItemsRepository for SqliteDb {
408 fn init(&self) -> RepoResult<()> {
409 let needs_legacy_migration = self
411 .conn
412 .query_row(
413 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapons'
414 AND sql LIKE '%id INTEGER PRIMARY KEY%'",
415 [],
416 |_| Ok(true),
417 )
418 .unwrap_or(false);
419
420 if needs_legacy_migration {
421 self.migrate_to_serial_pk()?;
422 }
423
424 self.conn
426 .execute(
427 "CREATE TABLE IF NOT EXISTS schema_migrations (
428 version TEXT PRIMARY KEY NOT NULL,
429 applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
430 )",
431 [],
432 )
433 .map_err(|e| RepoError::Database(e.to_string()))?;
434
435 self.run_migrations()?;
437
438 Ok(())
439 }
440
441 fn add_item(&self, serial: &str) -> RepoResult<()> {
442 self.conn
443 .execute("INSERT INTO items (serial) VALUES (?1)", params![serial])
444 .map_err(|e| RepoError::Database(e.to_string()))?;
445 Ok(())
446 }
447
448 fn get_item(&self, serial: &str) -> RepoResult<Option<Item>> {
449 let sql = format!(
450 "SELECT {} FROM items WHERE serial = ?1",
451 ITEM_SELECT_COLUMNS
452 );
453 let mut stmt = self
454 .conn
455 .prepare(&sql)
456 .map_err(|e| RepoError::Database(e.to_string()))?;
457 let item = stmt
458 .query_row(params![serial], row_to_item)
459 .optional()
460 .map_err(|e| RepoError::Database(e.to_string()))?;
461 Ok(item)
462 }
463
464 fn update_item(&self, serial: &str, update: &ItemUpdate) -> RepoResult<()> {
465 self.conn
466 .execute(
467 r#"UPDATE items SET
468 name = COALESCE(?2, name),
469 prefix = COALESCE(?3, prefix),
470 manufacturer = COALESCE(?4, manufacturer),
471 weapon_type = COALESCE(?5, weapon_type),
472 rarity = COALESCE(?6, rarity),
473 level = COALESCE(?7, level),
474 element = COALESCE(?8, element),
475 dps = COALESCE(?9, dps),
476 damage = COALESCE(?10, damage),
477 accuracy = COALESCE(?11, accuracy),
478 fire_rate = COALESCE(?12, fire_rate),
479 reload_time = COALESCE(?13, reload_time),
480 mag_size = COALESCE(?14, mag_size),
481 value = COALESCE(?15, value),
482 red_text = COALESCE(?16, red_text),
483 notes = COALESCE(?17, notes)
484 WHERE serial = ?1"#,
485 params![
486 serial,
487 update.name,
488 update.prefix,
489 update.manufacturer,
490 update.weapon_type,
491 update.rarity,
492 update.level,
493 update.element,
494 update.dps,
495 update.damage,
496 update.accuracy,
497 update.fire_rate,
498 update.reload_time,
499 update.mag_size,
500 update.value,
501 update.red_text,
502 update.notes
503 ],
504 )
505 .map_err(|e| RepoError::Database(e.to_string()))?;
506 Ok(())
507 }
508
509 fn list_items(&self, filter: &ItemFilter) -> RepoResult<Vec<Item>> {
510 let (sql, params_vec) = build_list_query(filter);
511 let mut stmt = self
512 .conn
513 .prepare(&sql)
514 .map_err(|e| RepoError::Database(e.to_string()))?;
515 let params_refs: Vec<&dyn rusqlite::ToSql> =
516 params_vec.iter().map(|p| p.as_ref()).collect();
517 let items = stmt
518 .query_map(params_refs.as_slice(), row_to_item)
519 .map_err(|e| RepoError::Database(e.to_string()))?
520 .collect::<Result<Vec<_>, _>>()
521 .map_err(|e| RepoError::Database(e.to_string()))?;
522 Ok(items)
523 }
524
525 fn delete_item(&self, serial: &str) -> RepoResult<bool> {
526 let rows = self
527 .conn
528 .execute("DELETE FROM items WHERE serial = ?1", params![serial])
529 .map_err(|e| RepoError::Database(e.to_string()))?;
530 Ok(rows > 0)
531 }
532
533 fn set_verification_status(
534 &self,
535 serial: &str,
536 status: VerificationStatus,
537 notes: Option<&str>,
538 ) -> RepoResult<()> {
539 self.conn
540 .execute(
541 r#"UPDATE items SET
542 verification_status = ?2,
543 verification_notes = COALESCE(?3, verification_notes),
544 verified_at = CASE WHEN ?2 != 'unverified' THEN CURRENT_TIMESTAMP ELSE verified_at END
545 WHERE serial = ?1"#,
546 params![serial, status.to_string(), notes],
547 )
548 .map_err(|e| RepoError::Database(e.to_string()))?;
549 Ok(())
550 }
551
552 fn set_legal(&self, serial: &str, legal: bool) -> RepoResult<()> {
553 self.conn
554 .execute(
555 "UPDATE items SET legal = ?2 WHERE serial = ?1",
556 params![serial, legal],
557 )
558 .map_err(|e| RepoError::Database(e.to_string()))?;
559 Ok(())
560 }
561
562 fn set_all_legal(&self, legal: bool) -> RepoResult<usize> {
563 let rows = self
564 .conn
565 .execute("UPDATE items SET legal = ?1", params![legal])
566 .map_err(|e| RepoError::Database(e.to_string()))?;
567 Ok(rows)
568 }
569
570 fn set_item_type(&self, serial: &str, item_type: &str) -> RepoResult<()> {
571 self.conn
572 .execute(
573 "UPDATE items SET item_type = ?2 WHERE serial = ?1",
574 params![serial, item_type],
575 )
576 .map_err(|e| RepoError::Database(e.to_string()))?;
577 Ok(())
578 }
579
580 fn set_source(&self, serial: &str, source: &str) -> RepoResult<()> {
581 self.conn
582 .execute(
583 "UPDATE items SET source = ?2 WHERE serial = ?1",
584 params![serial, source],
585 )
586 .map_err(|e| RepoError::Database(e.to_string()))?;
587 Ok(())
588 }
589
590 fn set_source_for_null(&self, source: &str) -> RepoResult<usize> {
591 let rows = self
592 .conn
593 .execute(
594 "UPDATE items SET source = ?1 WHERE source IS NULL",
595 params![source],
596 )
597 .map_err(|e| RepoError::Database(e.to_string()))?;
598 Ok(rows)
599 }
600
601 fn set_source_where(&self, source: &str, condition: &str) -> RepoResult<usize> {
602 let sql = format!("UPDATE items SET source = ?1 WHERE {}", condition);
603 let rows = self
604 .conn
605 .execute(&sql, params![source])
606 .map_err(|e| RepoError::Database(e.to_string()))?;
607 Ok(rows)
608 }
609
610 fn get_parts(&self, serial: &str) -> RepoResult<Vec<ItemPart>> {
611 let mut stmt = self
612 .conn
613 .prepare(
614 "SELECT id, item_serial, slot, part_index, part_name, manufacturer, effect,
615 verified, verification_method, verification_notes, verified_at
616 FROM item_parts WHERE item_serial = ?1",
617 )
618 .map_err(|e| RepoError::Database(e.to_string()))?;
619
620 let parts = stmt
621 .query_map(params![serial], |row| {
622 Ok(ItemPart {
623 id: row.get(0)?,
624 item_serial: row.get(1)?,
625 slot: row.get(2)?,
626 part_index: row.get(3)?,
627 part_name: row.get(4)?,
628 manufacturer: row.get(5)?,
629 effect: row.get(6)?,
630 verified: row.get::<_, Option<bool>>(7)?.unwrap_or(false),
631 verification_method: row.get(8)?,
632 verification_notes: row.get(9)?,
633 verified_at: row.get(10)?,
634 })
635 })
636 .map_err(|e| RepoError::Database(e.to_string()))?
637 .collect::<Result<Vec<_>, _>>()
638 .map_err(|e| RepoError::Database(e.to_string()))?;
639
640 Ok(parts)
641 }
642
643 fn set_value(
644 &self,
645 serial: &str,
646 field: &str,
647 value: &str,
648 source: ValueSource,
649 source_detail: Option<&str>,
650 confidence: Confidence,
651 ) -> RepoResult<()> {
652 self.conn
653 .execute(
654 r#"INSERT OR REPLACE INTO item_values
655 (item_serial, field, value, source, source_detail, confidence)
656 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
657 params![
658 serial,
659 field,
660 value,
661 source.to_string(),
662 source_detail,
663 confidence.to_string()
664 ],
665 )
666 .map_err(|e| RepoError::Database(e.to_string()))?;
667 Ok(())
668 }
669
670 fn get_values(&self, serial: &str, field: &str) -> RepoResult<Vec<ItemValue>> {
671 let mut stmt = self
672 .conn
673 .prepare(
674 "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
675 FROM item_values
676 WHERE item_serial = ?1 AND field = ?2
677 ORDER BY source DESC, confidence DESC",
678 )
679 .map_err(|e| RepoError::Database(e.to_string()))?;
680
681 let values = stmt
682 .query_map(params![serial, field], |row| {
683 let source_str: String = row.get(4)?;
684 let confidence_str: String = row.get(6)?;
685 Ok(ItemValue {
686 id: row.get(0)?,
687 item_serial: row.get(1)?,
688 field: row.get(2)?,
689 value: row.get(3)?,
690 source: source_str.parse().unwrap_or(ValueSource::CommunityTool),
691 source_detail: row.get(5)?,
692 confidence: confidence_str.parse().unwrap_or(Confidence::Uncertain),
693 created_at: row.get(7)?,
694 })
695 })
696 .map_err(|e| RepoError::Database(e.to_string()))?
697 .collect::<Result<Vec<_>, _>>()
698 .map_err(|e| RepoError::Database(e.to_string()))?;
699
700 Ok(values)
701 }
702
703 fn get_best_value(&self, serial: &str, field: &str) -> RepoResult<Option<ItemValue>> {
704 let values = self.get_values(serial, field)?;
705 Ok(pick_best_value(values))
706 }
707
708 fn get_all_values(&self, serial: &str) -> RepoResult<Vec<ItemValue>> {
709 let mut stmt = self
710 .conn
711 .prepare(
712 "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
713 FROM item_values
714 WHERE item_serial = ?1
715 ORDER BY field, source DESC, confidence DESC",
716 )
717 .map_err(|e| RepoError::Database(e.to_string()))?;
718
719 let values = stmt
720 .query_map(params![serial], |row| {
721 let source_str: String = row.get(4)?;
722 let confidence_str: String = row.get(6)?;
723 Ok(ItemValue {
724 id: row.get(0)?,
725 item_serial: row.get(1)?,
726 field: row.get(2)?,
727 value: row.get(3)?,
728 source: source_str.parse().unwrap_or(ValueSource::CommunityTool),
729 source_detail: row.get(5)?,
730 confidence: confidence_str.parse().unwrap_or(Confidence::Uncertain),
731 created_at: row.get(7)?,
732 })
733 })
734 .map_err(|e| RepoError::Database(e.to_string()))?
735 .collect::<Result<Vec<_>, _>>()
736 .map_err(|e| RepoError::Database(e.to_string()))?;
737
738 Ok(values)
739 }
740
741 fn get_best_values(&self, serial: &str) -> RepoResult<HashMap<String, String>> {
742 let all_values = self.get_all_values(serial)?;
743 Ok(best_values_by_field(all_values))
744 }
745
746 fn get_all_items_best_values(&self) -> RepoResult<HashMap<String, HashMap<String, String>>> {
747 let mut stmt = self
748 .conn
749 .prepare(
750 "SELECT item_serial, field, value, source, confidence
751 FROM item_values
752 ORDER BY item_serial, field, source DESC, confidence DESC",
753 )
754 .map_err(|e| RepoError::Database(e.to_string()))?;
755
756 let values: Vec<(String, String, String, String, String)> = stmt
757 .query_map([], |row| {
758 Ok((
759 row.get::<_, String>(0)?,
760 row.get::<_, String>(1)?,
761 row.get::<_, String>(2)?,
762 row.get::<_, String>(3)?,
763 row.get::<_, String>(4)?,
764 ))
765 })
766 .map_err(|e| RepoError::Database(e.to_string()))?
767 .filter_map(|r| r.ok())
768 .collect();
769
770 let mut result: HashMap<String, HashMap<String, (String, u8, u8)>> = HashMap::new();
771
772 for (serial, field, value, source_str, confidence_str) in values {
773 let source: ValueSource = source_str.parse().unwrap_or(ValueSource::CommunityTool);
774 let confidence: Confidence = confidence_str.parse().unwrap_or(Confidence::Uncertain);
775
776 let entry = result.entry(serial).or_default();
777 let current = entry.get(&field);
778
779 let should_replace = current
780 .map(
781 |(_, src_prio, conf_prio)| match source.priority().cmp(src_prio) {
782 std::cmp::Ordering::Greater => true,
783 std::cmp::Ordering::Equal => confidence.priority() > *conf_prio,
784 std::cmp::Ordering::Less => false,
785 },
786 )
787 .unwrap_or(true);
788
789 if should_replace {
790 entry.insert(field, (value, source.priority(), confidence.priority()));
791 }
792 }
793
794 Ok(result
795 .into_iter()
796 .map(|(serial, fields)| {
797 (
798 serial,
799 fields.into_iter().map(|(f, (v, _, _))| (f, v)).collect(),
800 )
801 })
802 .collect())
803 }
804
805 fn stats(&self) -> RepoResult<DbStats> {
806 let item_count: i64 = self
807 .conn
808 .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
809 .map_err(|e| RepoError::Database(e.to_string()))?;
810
811 let part_count: i64 = self
812 .conn
813 .query_row("SELECT COUNT(*) FROM item_parts", [], |row| row.get(0))
814 .map_err(|e| RepoError::Database(e.to_string()))?;
815
816 let attachment_count: i64 = self
817 .conn
818 .query_row("SELECT COUNT(*) FROM attachments", [], |row| row.get(0))
819 .map_err(|e| RepoError::Database(e.to_string()))?;
820
821 let value_count: i64 = self
822 .conn
823 .query_row("SELECT COUNT(*) FROM item_values", [], |row| row.get(0))
824 .map_err(|e| RepoError::Database(e.to_string()))?;
825
826 Ok(DbStats {
827 item_count,
828 part_count,
829 attachment_count,
830 value_count,
831 })
832 }
833
834 #[allow(clippy::too_many_lines)] fn migrate_column_values(&self, dry_run: bool) -> RepoResult<MigrationStats> {
836 let mut stats = MigrationStats::default();
837
838 let fields_to_migrate = [
839 ("name", "name"),
840 ("prefix", "prefix"),
841 ("manufacturer", "manufacturer"),
842 ("weapon_type", "weapon_type"),
843 ("item_type", "item_type"),
844 ("rarity", "rarity"),
845 ("level", "level"),
846 ("element", "element"),
847 ("dps", "dps"),
848 ("damage", "damage"),
849 ("accuracy", "accuracy"),
850 ("fire_rate", "fire_rate"),
851 ("reload_time", "reload_time"),
852 ("mag_size", "mag_size"),
853 ("value", "value"),
854 ("red_text", "red_text"),
855 ];
856
857 let mut stmt = self
858 .conn
859 .prepare(
860 "SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
861 level, element, dps, damage, accuracy, fire_rate, reload_time,
862 mag_size, value, red_text
863 FROM items",
864 )
865 .map_err(|e| RepoError::Database(e.to_string()))?;
866
867 let items: Vec<(String, Vec<Option<String>>)> = stmt
868 .query_map([], |row| {
869 let serial: String = row.get(0)?;
870 let values: Vec<Option<String>> = (1..=16)
871 .map(|i| {
872 row.get::<_, Option<String>>(i)
873 .or_else(|_| {
874 row.get::<_, Option<i32>>(i)
875 .map(|v| v.map(|n| n.to_string()))
876 })
877 .or_else(|_| {
878 row.get::<_, Option<f64>>(i)
879 .map(|v| v.map(|n| n.to_string()))
880 })
881 .unwrap_or(None)
882 })
883 .collect();
884 Ok((serial, values))
885 })
886 .map_err(|e| RepoError::Database(e.to_string()))?
887 .filter_map(|r| r.ok())
888 .collect();
889
890 for (serial, values) in items {
891 stats.items_processed += 1;
892
893 for (i, (_, field_name)) in fields_to_migrate.iter().enumerate() {
894 if let Some(value) = &values[i] {
895 if value.is_empty() {
896 continue;
897 }
898
899 let existing: Option<i64> = self
900 .conn
901 .query_row(
902 "SELECT 1 FROM item_values WHERE item_serial = ?1 AND field = ?2",
903 params![&serial, field_name],
904 |row| row.get(0),
905 )
906 .optional()
907 .map_err(|e| RepoError::Database(e.to_string()))?;
908
909 if existing.is_some() {
910 stats.values_skipped += 1;
911 continue;
912 }
913
914 if dry_run {
915 println!("Would migrate: {}.{} = {}", serial, field_name, value);
916 } else {
917 self.set_value(
918 &serial,
919 field_name,
920 value,
921 ValueSource::Decoder,
922 None,
923 Confidence::Inferred,
924 )?;
925 }
926 stats.values_migrated += 1;
927 }
928 }
929 }
930
931 Ok(stats)
932 }
933}
934
935#[cfg(feature = "attachments")]
936impl AttachmentsRepository for SqliteDb {
937 fn add_attachment(
938 &self,
939 serial: &str,
940 name: &str,
941 mime_type: &str,
942 data: &[u8],
943 view: &str,
944 ) -> RepoResult<i64> {
945 self.conn
946 .execute(
947 "INSERT INTO attachments (item_serial, name, mime_type, data, view) VALUES (?1, ?2, ?3, ?4, ?5)",
948 params![serial, name, mime_type, data, view],
949 )
950 .map_err(|e| RepoError::Database(e.to_string()))?;
951 Ok(self.conn.last_insert_rowid())
952 }
953
954 fn get_attachments(&self, serial: &str) -> RepoResult<Vec<Attachment>> {
955 let mut stmt = self
956 .conn
957 .prepare(
958 "SELECT id, item_serial, name, mime_type, COALESCE(view, 'OTHER') FROM attachments WHERE item_serial = ?1",
959 )
960 .map_err(|e| RepoError::Database(e.to_string()))?;
961
962 let attachments = stmt
963 .query_map(params![serial], |row| {
964 Ok(Attachment {
965 id: row.get(0)?,
966 item_serial: row.get(1)?,
967 name: row.get(2)?,
968 mime_type: row.get(3)?,
969 view: row.get(4)?,
970 })
971 })
972 .map_err(|e| RepoError::Database(e.to_string()))?
973 .collect::<Result<Vec<_>, _>>()
974 .map_err(|e| RepoError::Database(e.to_string()))?;
975
976 Ok(attachments)
977 }
978
979 fn get_attachment_data(&self, id: i64) -> RepoResult<Option<Vec<u8>>> {
980 let mut stmt = self
981 .conn
982 .prepare("SELECT data FROM attachments WHERE id = ?1")
983 .map_err(|e| RepoError::Database(e.to_string()))?;
984
985 let data = stmt
986 .query_row(params![id], |row| row.get(0))
987 .optional()
988 .map_err(|e| RepoError::Database(e.to_string()))?;
989
990 Ok(data)
991 }
992
993 fn delete_attachment(&self, id: i64) -> RepoResult<bool> {
994 let rows = self
995 .conn
996 .execute("DELETE FROM attachments WHERE id = ?1", params![id])
997 .map_err(|e| RepoError::Database(e.to_string()))?;
998 Ok(rows > 0)
999 }
1000}
1001
1002#[cfg(feature = "attachments")]
1004impl SqliteDb {
1005 pub fn get_attachments_bulk(&self, serials: &[&str]) -> RepoResult<Vec<Attachment>> {
1007 if serials.is_empty() {
1008 return Ok(vec![]);
1009 }
1010
1011 let placeholders: Vec<String> = (1..=serials.len()).map(|i| format!("?{}", i)).collect();
1013 let sql = format!(
1014 "SELECT id, item_serial, name, mime_type, COALESCE(view, 'OTHER') FROM attachments WHERE item_serial IN ({})",
1015 placeholders.join(", ")
1016 );
1017
1018 let mut stmt = self
1019 .conn
1020 .prepare(&sql)
1021 .map_err(|e| RepoError::Database(e.to_string()))?;
1022
1023 let attachments = stmt
1024 .query_map(rusqlite::params_from_iter(serials.iter()), |row| {
1025 Ok(Attachment {
1026 id: row.get(0)?,
1027 item_serial: row.get(1)?,
1028 name: row.get(2)?,
1029 mime_type: row.get(3)?,
1030 view: row.get(4)?,
1031 })
1032 })
1033 .map_err(|e| RepoError::Database(e.to_string()))?
1034 .collect::<Result<Vec<_>, _>>()
1035 .map_err(|e| RepoError::Database(e.to_string()))?;
1036
1037 Ok(attachments)
1038 }
1039
1040 pub fn get_attachment_data_bulk(&self, ids: &[i64]) -> RepoResult<Vec<(i64, Vec<u8>)>> {
1042 if ids.is_empty() {
1043 return Ok(vec![]);
1044 }
1045
1046 let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
1047 let sql = format!(
1048 "SELECT id, data FROM attachments WHERE id IN ({})",
1049 placeholders.join(", ")
1050 );
1051
1052 let mut stmt = self
1053 .conn
1054 .prepare(&sql)
1055 .map_err(|e| RepoError::Database(e.to_string()))?;
1056
1057 let data = stmt
1058 .query_map(rusqlite::params_from_iter(ids.iter()), |row| {
1059 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
1060 })
1061 .map_err(|e| RepoError::Database(e.to_string()))?
1062 .collect::<Result<Vec<_>, _>>()
1063 .map_err(|e| RepoError::Database(e.to_string()))?;
1064
1065 Ok(data)
1066 }
1067}
1068
1069impl SqliteDb {
1071 pub fn get_all_values_bulk(&self, serials: &[&str]) -> RepoResult<Vec<ItemValue>> {
1073 if serials.is_empty() {
1074 return Ok(vec![]);
1075 }
1076
1077 let placeholders: Vec<String> = (1..=serials.len()).map(|i| format!("?{}", i)).collect();
1078 let sql = format!(
1079 "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
1080 FROM item_values WHERE item_serial IN ({})
1081 ORDER BY item_serial, field, source DESC, confidence DESC",
1082 placeholders.join(", ")
1083 );
1084
1085 let mut stmt = self
1086 .conn
1087 .prepare(&sql)
1088 .map_err(|e| RepoError::Database(e.to_string()))?;
1089
1090 let values = stmt
1091 .query_map(rusqlite::params_from_iter(serials.iter()), |row| {
1092 Ok(ItemValue {
1093 id: row.get(0)?,
1094 item_serial: row.get(1)?,
1095 field: row.get(2)?,
1096 value: row.get(3)?,
1097 source: row
1098 .get::<_, String>(4)?
1099 .parse()
1100 .unwrap_or(ValueSource::Decoder),
1101 source_detail: row.get(5)?,
1102 confidence: row
1103 .get::<_, String>(6)?
1104 .parse()
1105 .unwrap_or(Confidence::Uncertain),
1106 created_at: row.get(7)?,
1107 })
1108 })
1109 .map_err(|e| RepoError::Database(e.to_string()))?
1110 .collect::<Result<Vec<_>, _>>()
1111 .map_err(|e| RepoError::Database(e.to_string()))?;
1112
1113 Ok(values)
1114 }
1115}
1116
1117impl ImportExportRepository for SqliteDb {
1118 fn import_from_dir(&self, dir: &Path) -> RepoResult<String> {
1119 let serial_path = dir.join("serial.txt");
1120 let serial = std::fs::read_to_string(&serial_path)?.trim().to_string();
1121
1122 if self.get_item(&serial)?.is_some() {
1123 println!("Item already exists: {}", serial);
1124 return Ok(serial);
1125 }
1126
1127 self.add_item(&serial)?;
1128
1129 let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1130 let parts: Vec<&str> = dir_name.split('_').collect();
1131
1132 if parts.len() >= 2 {
1133 let update = ItemUpdate {
1134 manufacturer: Some(parts[0].to_string()),
1135 weapon_type: Some(parts[1].to_string()),
1136 name: if parts.len() > 2 {
1137 Some(parts[2..].join("_").replace('_', " "))
1138 } else {
1139 None
1140 },
1141 ..Default::default()
1142 };
1143 self.update_item(&serial, &update)?;
1144 }
1145
1146 #[cfg(feature = "attachments")]
1147 {
1148 for entry in std::fs::read_dir(dir)? {
1149 let entry = entry?;
1150 let path = entry.path();
1151
1152 if path.extension().map(|e| e == "png").unwrap_or(false) {
1153 let name = path
1154 .file_stem()
1155 .and_then(|n| n.to_str())
1156 .unwrap_or("unknown");
1157 let view = match name {
1158 "inventory" | "stats" => "POPUP",
1159 "inspect" => "DETAIL",
1160 _ => "OTHER",
1161 };
1162 let data = std::fs::read(&path)?;
1163 self.add_attachment(&serial, name, "image/png", &data, view)?;
1164 }
1165 }
1166 }
1167
1168 Ok(serial)
1169 }
1170
1171 fn export_to_dir(&self, serial: &str, dir: &Path) -> RepoResult<()> {
1172 std::fs::create_dir_all(dir)?;
1173
1174 let item = self
1175 .get_item(serial)?
1176 .ok_or_else(|| RepoError::NotFound(serial.to_string()))?;
1177
1178 std::fs::write(dir.join("serial.txt"), &item.serial)?;
1179
1180 let metadata =
1181 serde_json::to_string_pretty(&item).map_err(|e| RepoError::Database(e.to_string()))?;
1182 std::fs::write(dir.join("metadata.json"), metadata)?;
1183
1184 #[cfg(feature = "attachments")]
1185 {
1186 let attachments = self.get_attachments(serial)?;
1187 for attachment in attachments {
1188 if let Some(data) = self.get_attachment_data(attachment.id)? {
1189 let ext = match attachment.mime_type.as_str() {
1190 "image/png" => "png",
1191 "image/jpeg" => "jpg",
1192 _ => "bin",
1193 };
1194 let filename = format!("{}.{}", attachment.name, ext);
1195 std::fs::write(dir.join(filename), data)?;
1196 }
1197 }
1198 }
1199
1200 Ok(())
1201 }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207
1208 fn setup_db() -> SqliteDb {
1209 let db = SqliteDb::open_in_memory().unwrap();
1210 db.init().unwrap();
1211 db
1212 }
1213
1214 #[test]
1215 fn test_init_creates_tables() {
1216 let db = setup_db();
1217 let count: i64 = db
1219 .conn
1220 .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
1221 .unwrap();
1222 assert_eq!(count, 0);
1223 }
1224
1225 #[test]
1226 fn test_add_and_get_item() {
1227 let db = setup_db();
1228 let serial = "BL4(AwAAAACo4A==)";
1229
1230 db.add_item(serial).unwrap();
1231
1232 let item = db.get_item(serial).unwrap().unwrap();
1233 assert_eq!(item.serial, serial);
1234 }
1235
1236 #[test]
1237 fn test_add_duplicate_item_errors() {
1238 let db = setup_db();
1239 let serial = "BL4(AwAAAACo4A==)";
1240
1241 db.add_item(serial).unwrap();
1242 let result = db.add_item(serial);
1244 assert!(result.is_err());
1245
1246 let filter = ItemFilter::default();
1248 let items = db.list_items(&filter).unwrap();
1249 assert_eq!(items.len(), 1);
1250 }
1251
1252 #[test]
1253 fn test_update_item() {
1254 let db = setup_db();
1255 let serial = "BL4(AwAAAACo4A==)";
1256 db.add_item(serial).unwrap();
1257
1258 let update = ItemUpdate {
1259 name: Some("Test Weapon".to_string()),
1260 level: Some(50),
1261 rarity: Some("legendary".to_string()),
1262 ..Default::default()
1263 };
1264 db.update_item(serial, &update).unwrap();
1265
1266 let item = db.get_item(serial).unwrap().unwrap();
1267 assert_eq!(item.name, Some("Test Weapon".to_string()));
1268 assert_eq!(item.level, Some(50));
1269 assert_eq!(item.rarity, Some("legendary".to_string()));
1270 }
1271
1272 #[test]
1273 fn test_set_and_get_value() {
1274 let db = setup_db();
1275 let serial = "BL4(AwAAAACo4A==)";
1276 db.add_item(serial).unwrap();
1277
1278 db.set_value(
1279 serial,
1280 "name",
1281 "Test Name",
1282 ValueSource::Decoder,
1283 None,
1284 Confidence::Inferred,
1285 )
1286 .unwrap();
1287
1288 let values = db.get_values(serial, "name").unwrap();
1289 assert_eq!(values.len(), 1);
1290 assert_eq!(values[0].value, "Test Name");
1291 assert_eq!(values[0].source, ValueSource::Decoder);
1292 assert_eq!(values[0].confidence, Confidence::Inferred);
1293 }
1294
1295 #[test]
1296 fn test_get_best_values() {
1297 let db = setup_db();
1298 let serial = "BL4(AwAAAACo4A==)";
1299 db.add_item(serial).unwrap();
1300
1301 db.set_value(
1303 serial,
1304 "name",
1305 "Community Name",
1306 ValueSource::CommunityTool,
1307 Some("test-tool"),
1308 Confidence::Uncertain,
1309 )
1310 .unwrap();
1311
1312 db.set_value(
1314 serial,
1315 "name",
1316 "Decoder Name",
1317 ValueSource::Decoder,
1318 None,
1319 Confidence::Inferred,
1320 )
1321 .unwrap();
1322
1323 let best = db.get_best_values(serial).unwrap();
1324 assert_eq!(best.get("name"), Some(&"Decoder Name".to_string()));
1326 }
1327
1328 #[test]
1329 fn test_list_items_with_filter() {
1330 let db = setup_db();
1331
1332 db.add_item("BL4(item1)").unwrap();
1333 db.add_item("BL4(item2)").unwrap();
1334 db.add_item("BL4(item3)").unwrap();
1335
1336 db.update_item(
1337 "BL4(item1)",
1338 &ItemUpdate {
1339 rarity: Some("legendary".to_string()),
1340 ..Default::default()
1341 },
1342 )
1343 .unwrap();
1344 db.update_item(
1345 "BL4(item2)",
1346 &ItemUpdate {
1347 rarity: Some("epic".to_string()),
1348 ..Default::default()
1349 },
1350 )
1351 .unwrap();
1352 db.update_item(
1353 "BL4(item3)",
1354 &ItemUpdate {
1355 rarity: Some("legendary".to_string()),
1356 ..Default::default()
1357 },
1358 )
1359 .unwrap();
1360
1361 let filter = ItemFilter {
1362 rarity: Some("legendary".to_string()),
1363 ..Default::default()
1364 };
1365 let items = db.list_items(&filter).unwrap();
1366 assert_eq!(items.len(), 2);
1367 }
1368
1369 #[test]
1370 fn test_list_items_count() {
1371 let db = setup_db();
1372
1373 db.add_item("BL4(item1)").unwrap();
1374 db.add_item("BL4(item2)").unwrap();
1375 db.add_item("BL4(item3)").unwrap();
1376
1377 let items = db.list_items(&ItemFilter::default()).unwrap();
1378 assert_eq!(items.len(), 3);
1379 }
1380
1381 #[test]
1382 fn test_stats() {
1383 let db = setup_db();
1384
1385 db.add_item("BL4(item1)").unwrap();
1386 db.add_item("BL4(item2)").unwrap();
1387
1388 db.set_value(
1389 "BL4(item1)",
1390 "name",
1391 "Test",
1392 ValueSource::Decoder,
1393 None,
1394 Confidence::Inferred,
1395 )
1396 .unwrap();
1397
1398 let stats = db.stats().unwrap();
1399 assert_eq!(stats.item_count, 2);
1400 assert_eq!(stats.value_count, 1);
1401 }
1402
1403 #[test]
1404 fn test_set_item_type() {
1405 let db = setup_db();
1406 let serial = "BL4(AwAAAACo4A==)";
1407 db.add_item(serial).unwrap();
1408
1409 db.set_item_type(serial, "weapon").unwrap();
1410
1411 let item = db.get_item(serial).unwrap().unwrap();
1412 assert_eq!(item.item_type, Some("weapon".to_string()));
1413 }
1414
1415 #[test]
1416 fn test_set_source() {
1417 let db = setup_db();
1418 let serial = "BL4(AwAAAACo4A==)";
1419 db.add_item(serial).unwrap();
1420
1421 db.set_source(serial, "community-pull").unwrap();
1422
1423 let item = db.get_item(serial).unwrap().unwrap();
1424 assert_eq!(item.source, Some("community-pull".to_string()));
1425 }
1426}