From 1a30c4ffe03ce4e56de8229277c5470c0a9242be Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 14:27:40 -0400 Subject: [PATCH] feat(core): add typed-item Manifest with schema_version 2 ManifestEntry holds the per-item browse summary including derived icon_hint (Login URL hostname) and attachment_summaries. Search matches title or tag substring case-insensitively. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 5 +- crates/idfoto-core/src/manifest.rs | 159 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 crates/idfoto-core/src/manifest.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index f4dd7b7..9cbb180 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -50,11 +50,14 @@ pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; pub use attachment::{AttachmentRef, AttachmentSummary}; +pub mod manifest; +pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; pub mod entry; -pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; +pub use entry::{generate_entry_id, Entry}; pub mod vault; pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; diff --git a/crates/idfoto-core/src/manifest.rs b/crates/idfoto-core/src/manifest.rs new file mode 100644 index 0000000..e029f33 --- /dev/null +++ b/crates/idfoto-core/src/manifest.rs @@ -0,0 +1,159 @@ +//! New typed-item manifest. Lives next to the old entry.rs Manifest +//! during this rewrite; entry.rs is deleted in Task 25. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::attachment::AttachmentSummary; +use crate::ids::ItemId; +use crate::item::Item; +use crate::item_types::ItemType; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub schema_version: u32, + pub items: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub id: ItemId, + pub r#type: ItemType, + pub title: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_hint: Option, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + #[serde(default)] + pub attachment_summaries: Vec, +} + +impl Manifest { + pub fn new() -> Self { + Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() } + } + + pub fn upsert(&mut self, item: &Item) { + let entry = ManifestEntry::from_item(item); + self.items.insert(item.id.clone(), entry); + } + + pub fn remove(&mut self, id: &ItemId) -> Option { + self.items.remove(id) + } + + pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> { + self.items.get(id) + } + + /// Case-insensitive substring match on title and tags. + pub fn search(&self, query: &str) -> Vec<&ManifestEntry> { + let q = query.to_lowercase(); + self.items + .values() + .filter(|e| { + e.title.to_lowercase().contains(&q) + || e.tags.iter().any(|t| t.to_lowercase().contains(&q)) + }) + .collect() + } +} + +impl Default for Manifest { + fn default() -> Self { Self::new() } +} + +impl ManifestEntry { + pub fn from_item(item: &Item) -> Self { + Self { + id: item.id.clone(), + r#type: item.r#type, + title: item.title.clone(), + tags: item.tags.clone(), + favorite: item.favorite, + group: item.group.clone(), + icon_hint: derive_icon_hint(item), + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.iter().map(Into::into).collect(), + } + } +} + +/// Derive an icon hint string from an item — for Login items, this is the URL hostname. +fn derive_icon_hint(item: &Item) -> Option { + use crate::item_types::ItemCore; + match &item.core { + ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; + + #[test] + fn empty_manifest_has_schema_v2() { + let m = Manifest::new(); + assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION); + assert!(m.items.is_empty()); + } + + #[test] + fn upsert_and_search() { + let mut m = Manifest::new(); + let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default())); + item.tags = vec!["work".into()]; + m.upsert(&item); + + let results = m.search("github"); + assert_eq!(results.len(), 1); + let by_tag = m.search("work"); + assert_eq!(by_tag.len(), 1); + } + + #[test] + fn icon_hint_is_login_url_host() { + use url::Url; + let mut m = Manifest::new(); + let core = ItemCore::Login(LoginCore { + url: Some(Url::parse("https://api.github.com/login").unwrap()), + ..Default::default() + }); + let item = Item::new("X".into(), core); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com")); + } + + #[test] + fn icon_hint_is_none_for_non_login() { + let mut m = Manifest::new(); + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert!(entry.icon_hint.is_none()); + } + + #[test] + fn manifest_round_trips() { + let mut m = Manifest::new(); + let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let json = serde_json::to_string(&m).unwrap(); + let parsed: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION); + assert_eq!(parsed.items.len(), 1); + } +}