diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index fe1ad23..082d576 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -38,6 +38,20 @@ pub struct NewMeeting { pub calendar_event_id: Option, } +/// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed +/// together; each is skipped when `None`. +#[derive(Debug, Default)] +pub struct MeetingFilter { + /// Title substring match — separate from full-text `search()`. + pub query: Option, + pub tag: Option, + /// Matches either a speaker mapped to this participant or an attendee of + /// the meeting's linked calendar event. + pub participant_id: Option, + pub from: Option, + pub to: Option, +} + /// What actually happened during the session — richer than the doc's bare /// `&MeetingId` because `finalize_meeting` has to persist what was captured. pub struct FinalizeMeeting { @@ -73,6 +87,8 @@ pub struct Meeting { /// The linked calendar event, if any (T6.3/T6.6, FR-CAL-2/4) — set either /// at `start_recording` time or later via `attach_meeting_to_event`. pub calendar_event_id: Option, + /// Tags (Phase 8, FR-SEARCH-2), sorted. + pub tags: Vec, } /// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the @@ -110,7 +126,7 @@ pub trait Store: Send + Sync { async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>; async fn list_meetings( &self, - query: Option, + filter: MeetingFilter, ) -> Result, StoreError>; async fn get_meeting(&self, id: &MeetingId) -> Result; async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; @@ -177,6 +193,11 @@ pub trait Store: Send + Sync { ) -> Result<(), StoreError>; /// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1). async fn search(&self, query: &str) -> Result, StoreError>; + /// Replaces this meeting's complete tag set (Phase 8, FR-SEARCH-2) — + /// not incremental add/remove. Unknown tag names are created. + async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError>; + /// All known tag names, sorted, for filter/autocomplete UI. + async fn list_tags(&self) -> Result, StoreError>; /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). async fn recover_scan(&self) -> Result, StoreError>; /// Enforce retention; returns count removed. Skips in-progress meetings @@ -296,6 +317,45 @@ impl SqliteStore { Ok(id) } + /// Finds-or-creates a `tags` row by name (Phase 8, FR-SEARCH-2). `name` is + /// `UNIQUE`, so — unlike `upsert_participant`'s nullable `email` — a plain + /// `INSERT OR IGNORE` conflict target is unambiguous here. + async fn upsert_tag(&self, name: &str) -> Result { + if let Some(id) = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?") + .bind(name) + .fetch_optional(&self.pool) + .await? + { + return Ok(id); + } + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)") + .bind(&id) + .bind(name) + .execute(&self.pool) + .await?; + // Someone else (a concurrent set_tags call) may have won the + // INSERT OR IGNORE race; re-look-up rather than assume our id landed. + sqlx::query_scalar("SELECT id FROM tags WHERE name = ?") + .bind(name) + .fetch_one(&self.pool) + .await + .map_err(Into::into) + } + + /// Tag names for one meeting, sorted (Phase 8, FR-SEARCH-2). + async fn tags_for_meeting(&self, meeting_id: &MeetingId) -> Result, StoreError> { + Ok(sqlx::query_scalar( + "SELECT t.name FROM tags t + JOIN meeting_tags mt ON mt.tag_id = t.id + WHERE mt.meeting_id = ? + ORDER BY t.name", + ) + .bind(meeting_id) + .fetch_all(&self.pool) + .await?) + } + /// (Re)builds this meeting's `meeting_fts` row from the current title and /// whatever's on disk for transcript/notes (Phase 8, FR-SEARCH-1). /// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps @@ -395,6 +455,8 @@ struct TranscriptSpeaker { display_name: Option, } +/// Builds everything but `tags` — the caller fills that in with an async +/// `tags_for_meeting` lookup, since sqlx rows can't be read across an await. fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem { MeetingListItem { id: row.get("id"), @@ -402,6 +464,7 @@ fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem { started_at: row.get("started_at"), duration_secs: row.get("duration_secs"), status: MeetingStatus::parse(row.get::("status").as_str()), + tags: Vec::new(), } } @@ -495,23 +558,69 @@ impl Store for SqliteStore { async fn list_meetings( &self, - query: Option, + filter: MeetingFilter, ) -> Result, StoreError> { - let rows = match query.filter(|q| !q.trim().is_empty()) { - Some(q) => { - let like = format!("%{}%", q.trim()); - sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings WHERE title LIKE ? ORDER BY started_at DESC") - .bind(like) - .fetch_all(&self.pool) - .await? - } - None => { - sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings ORDER BY started_at DESC") - .fetch_all(&self.pool) - .await? - } - }; - Ok(rows.iter().map(row_to_list_item).collect()) + // Conditions are pushed in a fixed order (tag, query, participant, + // from, to) and bound in that exact same order below — there's no + // named-parameter binding in this sqlx query style, so the two lists + // must stay in lockstep. + let mut sql = String::from("SELECT DISTINCT m.id, m.title, m.started_at, m.duration_secs, m.status FROM meetings m"); + if filter.tag.is_some() { + sql.push_str( + " JOIN meeting_tags mt ON mt.meeting_id = m.id JOIN tags t ON t.id = mt.tag_id", + ); + } + let mut conditions = Vec::new(); + if filter.tag.is_some() { + conditions.push("t.name = ?"); + } + let query = filter.query.filter(|q| !q.trim().is_empty()); + if query.is_some() { + conditions.push("m.title LIKE ?"); + } + if filter.participant_id.is_some() { + conditions.push( + "(EXISTS (SELECT 1 FROM speakers s WHERE s.meeting_id = m.id AND s.participant_id = ?) + OR EXISTS (SELECT 1 FROM calendar_event_participants cep WHERE cep.calendar_event_id = m.calendar_event_id AND cep.participant_id = ?))", + ); + } + if filter.from.is_some() { + conditions.push("m.started_at >= ?"); + } + if filter.to.is_some() { + conditions.push("m.started_at <= ?"); + } + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + sql.push_str(" ORDER BY m.started_at DESC"); + + let mut q = sqlx::query(&sql); + if let Some(tag) = &filter.tag { + q = q.bind(tag); + } + if let Some(query) = &query { + q = q.bind(format!("%{}%", query.trim())); + } + if let Some(pid) = &filter.participant_id { + q = q.bind(pid).bind(pid); + } + if let Some(from) = filter.from { + q = q.bind(from); + } + if let Some(to) = filter.to { + q = q.bind(to); + } + let rows = q.fetch_all(&self.pool).await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + let mut item = row_to_list_item(row); + item.tags = self.tags_for_meeting(&item.id).await?; + items.push(item); + } + Ok(items) } async fn get_meeting(&self, id: &MeetingId) -> Result { @@ -568,6 +677,7 @@ impl Store for SqliteStore { let summary = std::fs::read_to_string(folder.join("summary.json")) .ok() .and_then(|s| serde_json::from_str::(&s).ok()); + let tags = self.tags_for_meeting(id).await?; Ok(Meeting { id: row.get("id"), @@ -585,6 +695,7 @@ impl Store for SqliteStore { notes_markdown, summary, calendar_event_id: row.get("calendar_event_id"), + tags, }) } @@ -890,20 +1001,47 @@ impl Store for SqliteStore { .bind(&fts_query) .fetch_all(&self.pool) .await?; - Ok(rows - .iter() - .map(|row| { - let item = row_to_list_item(row); - SearchHit { - id: item.id, - title: item.title, - started_at: item.started_at, - duration_secs: item.duration_secs, - status: item.status, - snippet: row.get("snippet"), - } - }) - .collect()) + let mut hits = Vec::with_capacity(rows.len()); + for row in &rows { + let item = row_to_list_item(row); + let tags = self.tags_for_meeting(&item.id).await?; + hits.push(SearchHit { + id: item.id, + title: item.title, + started_at: item.started_at, + duration_secs: item.duration_secs, + status: item.status, + tags, + snippet: row.get("snippet"), + }); + } + Ok(hits) + } + + async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError> { + sqlx::query("DELETE FROM meeting_tags WHERE meeting_id = ?") + .bind(meeting_id) + .execute(&self.pool) + .await?; + for name in tags { + let name = name.trim(); + if name.is_empty() { + continue; + } + let tag_id = self.upsert_tag(name).await?; + sqlx::query("INSERT OR IGNORE INTO meeting_tags (meeting_id, tag_id) VALUES (?, ?)") + .bind(meeting_id) + .bind(&tag_id) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + async fn list_tags(&self) -> Result, StoreError> { + Ok(sqlx::query_scalar("SELECT name FROM tags ORDER BY name") + .fetch_all(&self.pool) + .await?) } async fn recover_scan(&self) -> Result, StoreError> {