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