Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,10 @@ func (s *Server) listMedia(ctx context.Context, _ *mcpsdk.CallToolRequest, in li
if err != nil {
return nil, listMediaOut{}, err
}
filter := store.GalleryFilter{ConversationID: convID, Source: in.Source, Limit: in.Limit}
filter := store.GalleryFilter{Source: in.Source, Limit: in.Limit}
if convID > 0 {
filter.ConversationIDs = []int64{convID}
}
var kinds []string
switch in.Kind {
case "image", "file":
Expand Down Expand Up @@ -358,10 +361,12 @@ func (s *Server) listLinks(ctx context.Context, _ *mcpsdk.CallToolRequest, in li
limit = 200
}
filter := store.GalleryFilter{
ConversationID: convID,
Source: in.Source,
Domain: strings.ToLower(strings.TrimPrefix(in.Domain, "www.")),
Limit: limit,
Source: in.Source,
Domain: strings.ToLower(strings.TrimPrefix(in.Domain, "www.")),
Limit: limit,
}
if convID > 0 {
filter.ConversationIDs = []int64{convID}
}
page, err := s.store.ListLinks(ctx, filter, store.LinkCursor{})
if err != nil {
Expand Down
48 changes: 31 additions & 17 deletions internal/store/gallery.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ import (
// construction: ReplaceConversationMessages stamps one source per replace and
// conversations are UNIQUE(source, name)).
type GalleryFilter struct {
ConversationID int64
Source string
Domain string // links only; exact match after www-stripping (MCP list_links)
StartUnix int64
EndUnix int64
Limit int
// ConversationIDs limits results to any of these conversations (issue #6:
// the Media filter is multi-select). Empty means all conversations; ids
// bind as an IN(...) parameter set, never string-interpolated.
ConversationIDs []int64
Source string
Domain string // links only; exact match after www-stripping (MCP list_links)
StartUnix int64
EndUnix int64
Limit int
// SortAsc flips the attachment walk to oldest-first; the zero value keeps
// the newest-first default. Links stay domain-ordered regardless (their
// display order is grouped, not chronological), so this only steers
Expand Down Expand Up @@ -90,14 +93,22 @@ type LinkPage struct {
Next LinkCursor
}

// inPlaceholders returns a "?,?,..." list of n bound-parameter placeholders
// for an IN(...) set.
func inPlaceholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}

// attachmentClauses builds WHERE clauses (alias a) for attachments queries.
// Only denormalized columns appear — see GalleryFilter.
func attachmentClauses(f GalleryFilter) ([]string, []any) {
var where []string
var args []any
if f.ConversationID > 0 {
where = append(where, "a.conversation_id = ?")
args = append(args, f.ConversationID)
if n := len(f.ConversationIDs); n > 0 {
where = append(where, "a.conversation_id IN ("+inPlaceholders(n)+")")
for _, id := range f.ConversationIDs {
args = append(args, id)
}
}
if f.Source != "" {
where = append(where, "a.conversation_id IN (SELECT id FROM conversations WHERE source = ?)")
Expand All @@ -119,9 +130,11 @@ func attachmentClauses(f GalleryFilter) ([]string, []any) {
func linkClauses(f GalleryFilter) ([]string, []any) {
var where []string
var args []any
if f.ConversationID > 0 {
where = append(where, "l.conversation_id = ?")
args = append(args, f.ConversationID)
if n := len(f.ConversationIDs); n > 0 {
where = append(where, "l.conversation_id IN ("+inPlaceholders(n)+")")
for _, id := range f.ConversationIDs {
args = append(args, id)
}
}
if f.Source != "" {
where = append(where, "l.conversation_id IN (SELECT id FROM conversations WHERE source = ?)")
Expand Down Expand Up @@ -179,17 +192,18 @@ func galleryLimit(f GalleryFilter, def, max int) int {
// within a kind, so the scan emits rows already in display order and stops
// at LIMIT — no sort, no messages touch (measured 357 ms → 10 ms).
// - Conversation-filtered paths seek idx_attachments_conv_kind instead: the
// candidate set is bounded by that conversation's attachments, so the
// residual sort is small; walking the kind_ts index here would degrade to
// a full-index walk for conversations with few recent attachments
// (measured 104 ms vs 1 ms on a sparse conversation).
// candidate set is bounded by the selected conversations' attachments
// (SQLite runs an IN(...) over the index's leading column as one seek per
// id), so the residual sort is small; walking the kind_ts index here would
// degrade to a full-index walk for conversations with few recent
// attachments (measured 104 ms vs 1 ms on a sparse conversation).
//
// messages and conversations are joined unaliased and only by INTEGER PRIMARY
// KEY for the ≤ limit result rows — plans SEARCH them, never SCAN.
func listAttachmentsSQL(kind string, f GalleryFilter, cursorTSUnix, cursorID int64, limit int) (string, []any) {
clauses, filterArgs := attachmentClauses(f)
idx := "idx_attachments_kind_ts"
if f.ConversationID > 0 {
if len(f.ConversationIDs) > 0 {
idx = "idx_attachments_conv_kind"
}
q := `
Expand Down
84 changes: 77 additions & 7 deletions internal/store/gallery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func TestListAttachments(t *testing.T) {
}

// Conversation filter: only Harper's image.
hImages, err := st.ListAttachments(ctx, "image", GalleryFilter{ConversationID: harper}, 0, 0)
hImages, err := st.ListAttachments(ctx, "image", GalleryFilter{ConversationIDs: []int64{harper}}, 0, 0)
if err != nil {
t.Fatal(err)
}
Expand All @@ -106,6 +106,72 @@ func TestListAttachments(t *testing.T) {
}
}

// TestGalleryMultiConversationFilter: GalleryFilter.ConversationIDs narrows to
// ANY of the selected conversations (issue #6) across the listing and count
// paths, and an empty set still means "all".
func TestGalleryMultiConversationFilter(t *testing.T) {
st, harper, group := seedGalleryCorpus(t)
ctx := context.Background()

// A third conversation the two-id filter must exclude.
zed, err := st.UpsertConversation(ctx, source.Signal, "Zed")
if err != nil {
t.Fatal(err)
}
zedMsgs := []signal.Message{
msg("Zed", "2022-05-01 10:00:00", "Zed", "pic",
[]signal.Attachment{{Kind: signal.KindImage, RelPath: "media/zed.jpg", OriginalName: "zed.jpg"}},
[]signal.Link{{URL: "https://zed.example.net/only"}}),
}
if _, err := st.ReplaceConversationMessages(ctx, zed, source.Signal, zedMsgs); err != nil {
t.Fatal(err)
}

both := GalleryFilter{ConversationIDs: []int64{harper, group}}

// Images from either selected conversation, still newest-first; Zed's
// May image is excluded despite being newest overall.
images, err := st.ListAttachments(ctx, "image", both, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(images.Items) != 2 || images.Items[0].OriginalName != "sunset.png" || images.Items[1].OriginalName != "cabin.jpg" {
t.Errorf("multi-conversation images = %+v, want [sunset.png cabin.jpg]", images.Items)
}

// Links: both selected conversations' URLs, Zed's excluded.
links, err := st.ListLinks(ctx, both, LinkCursor{})
if err != nil {
t.Fatal(err)
}
if len(links.Links) != 2 {
t.Errorf("multi-conversation links = %d, want 2: %+v", len(links.Links), links.Links)
}
for _, l := range links.Links {
if l.Domain == "zed.example.net" {
t.Errorf("multi-conversation links leaked an unselected conversation: %+v", l)
}
}

// Counts agree with the listings.
counts, err := st.CountMedia(ctx, both)
if err != nil {
t.Fatal(err)
}
if counts.Images != 2 || counts.Files != 1 || counts.Links != 2 {
t.Errorf("multi-conversation counts = %+v, want {Images:2 Files:1 Links:2}", counts)
}

// The empty set means all conversations — Zed included now.
all, err := st.CountMedia(ctx, GalleryFilter{})
if err != nil {
t.Fatal(err)
}
if all.Images != 3 || all.Links != 3 {
t.Errorf("unfiltered counts = %+v, want Images:3 Links:3", all)
}
}

// TestListAttachmentsSortAsc: the SortAsc filter flips the walk to oldest-first
// (issue #5) — the reverse of the default — and its keyset cursor still pages
// forward without overlap or gaps.
Expand Down Expand Up @@ -342,7 +408,7 @@ func TestCountMedia(t *testing.T) {
t.Errorf("counts = %+v, want {Images:2 Files:1 Links:2}", all)
}

h, err := st.CountMedia(ctx, GalleryFilter{ConversationID: harper})
h, err := st.CountMedia(ctx, GalleryFilter{ConversationIDs: []int64{harper}})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -407,14 +473,18 @@ func explainPlan(t *testing.T, st *Store, q string, args ...any) []string {
// appear only as bounded primary-key SEARCHes for the page's rows, and the
// attachment walk itself runs on an index (no whole-table sort).
func TestGalleryQueryPlans(t *testing.T) {
st, harper, _ := seedGalleryCorpus(t)
st, harper, group := seedGalleryCorpus(t)

filters := map[string]GalleryFilter{
"unfiltered": {},
"conversation": {ConversationID: harper},
"source": {Source: source.Signal},
"date": {StartUnix: 1, EndUnix: 2000000000},
"sort-asc": {SortAsc: true}, // oldest-first must also drive from the index
"conversation": {ConversationIDs: []int64{harper}},
// The multi-select IN(...) set (issue #6) must stay index-driven: SQLite
// runs the IN over idx_attachments_conv_kind's leading column as one
// seek per id, never a table scan.
"multi-conversation": {ConversationIDs: []int64{harper, group}},
"source": {Source: source.Signal},
"date": {StartUnix: 1, EndUnix: 2000000000},
"sort-asc": {SortAsc: true}, // oldest-first must also drive from the index
}

for name, f := range filters {
Expand Down
112 changes: 86 additions & 26 deletions internal/web/gallery.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ import (

// galleryFilterForm is the re-renderable filter state for the gallery.
type galleryFilterForm struct {
Tab string
ConversationID int64
Source string
Start string
End string
Tab string
// ConversationIDs is the multi-select conversation filter (issue #6): every
// checked conversation travels as its own repeated ?conversation= parameter.
// Empty means all conversations.
ConversationIDs []int64
Source string
Start string
End string
// Sort is the attachment display order (sortDesc default / sortAsc), carried
// on tab links and load-more URLs so it survives tab switches and
// infinite-scroll pagination. Mirrors the transcript's ?sort= convention.
Expand Down Expand Up @@ -71,17 +74,25 @@ type galleryData struct {
// id+name listing, NOT the sidebar summaries, so partial renders never need
// the expensive listing (SPEC-0008 REQ-0008-006). Ordered alphabetically.
FilterConversations []store.ConversationRef
Filter galleryFilterForm
Sources []string
Counts store.MediaCounts
ImagesPage galleryImagesData
FilesPage galleryFilesData
LinksPage galleryLinksData
// ConversationFilterLabel is the collapsed multi-select's summary text
// ("All conversations", one name, or "N conversations").
ConversationFilterLabel string
Filter galleryFilterForm
Sources []string
Counts store.MediaCounts
ImagesPage galleryImagesData
FilesPage galleryFilesData
LinksPage galleryLinksData
}

// validTabs are the gallery's three views.
var validTabs = map[string]bool{"images": true, "files": true, "links": true}

// maxConversationFilterIDs caps how many distinct ?conversation= ids a request
// may carry. The UI can never produce more than one per conversation, so the
// cap only bites hand-crafted URLs.
const maxConversationFilterIDs = 200

func (s *Server) handleGallery(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var base baseData
Expand Down Expand Up @@ -114,11 +125,12 @@ func (s *Server) handleGallery(w http.ResponseWriter, r *http.Request) {
}

data := galleryData{
baseData: base,
FilterConversations: refs,
Filter: form,
Sources: source.All,
Counts: counts,
baseData: base,
FilterConversations: refs,
ConversationFilterLabel: conversationFilterLabel(form.ConversationIDs, refs),
Filter: form,
Sources: source.All,
Counts: counts,
}

switch form.Tab {
Expand Down Expand Up @@ -257,7 +269,24 @@ func parseGalleryFilter(r *http.Request) (galleryFilterForm, store.GalleryFilter
if !validTabs[tab] {
tab = "images"
}
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation"), 10, 64)
// Multi-select conversation filter (issue #6): every checked box repeats
// ?conversation=. Garbage and duplicates drop out; an empty set means all.
// The set is capped well below SQLite's bound-parameter limit (32766 for
// modernc.org/sqlite) — each id becomes one IN(...) parameter, and without
// a cap a crafted URL could make every gallery query fail at prepare.
var convIDs []int64
seen := map[int64]bool{}
for _, raw := range r.URL.Query()["conversation"] {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 || seen[id] {
continue
}
seen[id] = true
convIDs = append(convIDs, id)
if len(convIDs) == maxConversationFilterIDs {
break
}
}
src := r.URL.Query().Get("source")
if !source.IsKnown(src) {
src = ""
Expand All @@ -266,24 +295,55 @@ func parseGalleryFilter(r *http.Request) (galleryFilterForm, store.GalleryFilter
end := r.URL.Query().Get("end")
sort := parseSort(r) // sortDesc (newest-first) default; sortAsc for oldest-first

form := galleryFilterForm{Tab: tab, ConversationID: convID, Source: src, Start: start, End: end, Sort: sort}
form := galleryFilterForm{Tab: tab, ConversationIDs: convIDs, Source: src, Start: start, End: end, Sort: sort}
filter := store.GalleryFilter{
ConversationID: convID,
Source: src,
StartUnix: dayStartUnix(start),
EndUnix: dayEndUnix(end),
SortAsc: sort == sortAsc,
ConversationIDs: convIDs,
Source: src,
StartUnix: dayStartUnix(start),
EndUnix: dayEndUnix(end),
SortAsc: sort == sortAsc,
}
return form, filter
}

// HasConversation reports whether the given conversation id is part of the
// active filter. Exported so the template can mark its checkbox checked.
func (f galleryFilterForm) HasConversation(id int64) bool {
for _, c := range f.ConversationIDs {
if c == id {
return true
}
}
return false
}

// conversationFilterLabel is the collapsed multi-select's summary text: the
// one selected conversation's display name, a count for several, or "All
// conversations" for none. Ids that no longer resolve to a conversation still
// count — the filter genuinely narrows to them (to nothing).
func conversationFilterLabel(ids []int64, refs []store.ConversationRef) string {
switch len(ids) {
case 0:
return "All conversations"
case 1:
for _, ref := range refs {
if ref.ID == ids[0] {
return humanName(ref.Name)
}
}
return "1 conversation"
default:
return strconv.Itoa(len(ids)) + " conversations"
}
}

// filterValues returns the querystring values that preserve the current
// filters across tab switches and pagination requests.
func (f galleryFilterForm) filterValues(tab string) url.Values {
v := url.Values{}
v.Set("tab", tab)
if f.ConversationID > 0 {
v.Set("conversation", strconv.FormatInt(f.ConversationID, 10))
for _, id := range f.ConversationIDs {
v.Add("conversation", strconv.FormatInt(id, 10))
}
if f.Source != "" {
v.Set("source", f.Source)
Expand Down Expand Up @@ -315,7 +375,7 @@ func (f galleryFilterForm) GalleryQuery(tab string) string {
// links, so the deep link's shape (and parseGalleryFilter round-trip) stays
// identical to what the gallery emits for itself.
func galleryConvURL(tab string, convID int64) string {
return galleryFilterForm{Tab: tab, ConversationID: convID}.GalleryQuery(tab)
return galleryFilterForm{Tab: tab, ConversationIDs: []int64{convID}}.GalleryQuery(tab)
}

// attachmentsNextURL builds the /gallery/items URL for the page after this
Expand Down
Loading
Loading