Skip to content

Commit 93684d9

Browse files
committed
feat: add collections and multi-source query filtering
Named collections grouping multiple sources with a default "All" collection. SourceIDs filtering in both DuckDB and SQLite query paths. CLI collections command with CRUD operations. Includes fixes for buffer corruption in normalizeRawMIME, deep-copy in Clone(), and openStore consolidation.
1 parent 4c2191f commit 93684d9

11 files changed

Lines changed: 850 additions & 59 deletions

File tree

cmd/msgvault/cmd/account_scope.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56

67
"github.com/wesm/msgvault/internal/store"
@@ -9,35 +10,45 @@ import (
910
// AccountScope is the result of resolving a user-supplied --account
1011
// flag against the store.
1112
type AccountScope struct {
12-
Input string
13-
Source *store.Source
13+
Input string
14+
Source *store.Source
15+
Collection *store.CollectionWithSources
1416
}
1517

1618
// IsEmpty reports whether the scope resolved to nothing.
1719
func (s AccountScope) IsEmpty() bool {
18-
return s.Source == nil
20+
return s.Source == nil && s.Collection == nil
21+
}
22+
23+
// IsCollection reports whether the scope refers to a collection.
24+
func (s AccountScope) IsCollection() bool {
25+
return s.Collection != nil
1926
}
2027

2128
// SourceIDs returns the source IDs that this scope expands to.
2229
func (s AccountScope) SourceIDs() []int64 {
23-
if s.Source != nil {
30+
switch {
31+
case s.Collection != nil:
32+
return append([]int64(nil), s.Collection.SourceIDs...)
33+
case s.Source != nil:
2434
return []int64{s.Source.ID}
2535
}
2636
return nil
2737
}
2838

2939
// DisplayName returns a human-readable label for the scope.
3040
func (s AccountScope) DisplayName() string {
31-
if s.Source != nil {
41+
switch {
42+
case s.Collection != nil:
43+
return s.Collection.Name
44+
case s.Source != nil:
3245
return s.Source.Identifier
3346
}
3447
return ""
3548
}
3649

3750
// ResolveAccount resolves a user-supplied --account string against
38-
// the store. Returns an empty scope if input is empty. Currently
39-
// looks up sources by identifier or display name; collection lookup
40-
// will be added when collections are implemented.
51+
// the store. Collections are checked first, then sources.
4152
func ResolveAccount(
4253
st *store.Store, input string,
4354
) (AccountScope, error) {
@@ -46,6 +57,21 @@ func ResolveAccount(
4657
return scope, nil
4758
}
4859

60+
// Try collection first.
61+
coll, err := st.GetCollectionByName(input)
62+
switch {
63+
case err == nil:
64+
scope.Collection = coll
65+
return scope, nil
66+
case errors.Is(err, store.ErrCollectionNotFound):
67+
// Fall through to source lookup.
68+
default:
69+
return scope, fmt.Errorf(
70+
"look up collection %q: %w", input, err,
71+
)
72+
}
73+
74+
// Source lookup.
4975
sources, err := st.GetSourcesByIdentifierOrDisplayName(input)
5076
if err != nil {
5177
return scope, fmt.Errorf(
@@ -54,8 +80,9 @@ func ResolveAccount(
5480
}
5581
if len(sources) == 0 {
5682
return scope, fmt.Errorf(
57-
"no account or source found for %q "+
58-
"(try 'msgvault list-accounts')",
83+
"no collection or source found for %q "+
84+
"(try 'msgvault collections list' or "+
85+
"'msgvault list-accounts')",
5986
input,
6087
)
6188
}

cmd/msgvault/cmd/collections.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
"strings"
8+
"text/tabwriter"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/wesm/msgvault/internal/store"
12+
)
13+
14+
var collectionsCmd = &cobra.Command{
15+
Use: "collections",
16+
Short: "Manage named groups of accounts",
17+
Long: `Collections are named groupings of accounts that let you view and
18+
deduplicate across multiple sources as one unified archive.
19+
20+
A default "All" collection is created automatically and includes
21+
every account.`,
22+
}
23+
24+
var collectionsCreateCmd = &cobra.Command{
25+
Use: "create <name> --accounts <email1,email2,...>",
26+
Short: "Create a new collection",
27+
Args: cobra.ExactArgs(1),
28+
RunE: runCollectionsCreate,
29+
}
30+
31+
var collectionsListCmd = &cobra.Command{
32+
Use: "list",
33+
Short: "List all collections",
34+
RunE: runCollectionsList,
35+
}
36+
37+
var collectionsShowCmd = &cobra.Command{
38+
Use: "show <name>",
39+
Short: "Show collection details",
40+
Args: cobra.ExactArgs(1),
41+
RunE: runCollectionsShow,
42+
}
43+
44+
var collectionsAddCmd = &cobra.Command{
45+
Use: "add <name> --accounts <email1,email2,...>",
46+
Short: "Add accounts to a collection",
47+
Args: cobra.ExactArgs(1),
48+
RunE: runCollectionsAdd,
49+
}
50+
51+
var collectionsRemoveCmd = &cobra.Command{
52+
Use: "remove <name> --accounts <email1,email2,...>",
53+
Short: "Remove accounts from a collection",
54+
Args: cobra.ExactArgs(1),
55+
RunE: runCollectionsRemove,
56+
}
57+
58+
var collectionsDeleteCmd = &cobra.Command{
59+
Use: "delete <name>",
60+
Short: "Delete a collection (sources and messages are untouched)",
61+
Args: cobra.ExactArgs(1),
62+
RunE: runCollectionsDelete,
63+
}
64+
65+
var collectionsAccounts string
66+
67+
func runCollectionsCreate(_ *cobra.Command, args []string) error {
68+
st, err := openStoreAndInit()
69+
if err != nil {
70+
return err
71+
}
72+
defer func() { _ = st.Close() }()
73+
74+
name := args[0]
75+
sourceIDs, err := resolveAccountList(st, collectionsAccounts)
76+
if err != nil {
77+
return err
78+
}
79+
80+
coll, err := st.CreateCollection(name, "", sourceIDs)
81+
if err != nil {
82+
return err
83+
}
84+
fmt.Printf("Created collection %q with %d source(s).\n",
85+
coll.Name, len(sourceIDs))
86+
return nil
87+
}
88+
89+
func runCollectionsList(_ *cobra.Command, _ []string) error {
90+
st, err := openStoreAndInit()
91+
if err != nil {
92+
return err
93+
}
94+
defer func() { _ = st.Close() }()
95+
96+
collections, err := st.ListCollections()
97+
if err != nil {
98+
return err
99+
}
100+
if len(collections) == 0 {
101+
fmt.Println("No collections.")
102+
return nil
103+
}
104+
105+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
106+
_, _ = fmt.Fprintln(w, "NAME\tSOURCES\tMESSAGES")
107+
for _, c := range collections {
108+
_, _ = fmt.Fprintf(w, "%s\t%d\t%s\n",
109+
c.Name, len(c.SourceIDs),
110+
formatCount(c.MessageCount))
111+
}
112+
_ = w.Flush()
113+
return nil
114+
}
115+
116+
func runCollectionsShow(_ *cobra.Command, args []string) error {
117+
st, err := openStoreAndInit()
118+
if err != nil {
119+
return err
120+
}
121+
defer func() { _ = st.Close() }()
122+
123+
coll, err := st.GetCollectionByName(args[0])
124+
if err != nil {
125+
return err
126+
}
127+
128+
fmt.Printf("Collection: %s\n", coll.Name)
129+
if coll.Description != "" {
130+
fmt.Printf("Description: %s\n", coll.Description)
131+
}
132+
fmt.Printf("Sources: %d\n", len(coll.SourceIDs))
133+
fmt.Printf("Messages: %s\n", formatCount(coll.MessageCount))
134+
fmt.Printf("Created: %s\n", coll.CreatedAt.Format("2006-01-02 15:04"))
135+
136+
if len(coll.SourceIDs) > 0 {
137+
fmt.Println("\nMember source IDs:", coll.SourceIDs)
138+
}
139+
return nil
140+
}
141+
142+
func runCollectionsAdd(_ *cobra.Command, args []string) error {
143+
st, err := openStoreAndInit()
144+
if err != nil {
145+
return err
146+
}
147+
defer func() { _ = st.Close() }()
148+
149+
sourceIDs, err := resolveAccountList(st, collectionsAccounts)
150+
if err != nil {
151+
return err
152+
}
153+
154+
if err := st.AddSourcesToCollection(args[0], sourceIDs); err != nil {
155+
return err
156+
}
157+
fmt.Printf("Added %d source(s) to %q.\n", len(sourceIDs), args[0])
158+
return nil
159+
}
160+
161+
func runCollectionsRemove(_ *cobra.Command, args []string) error {
162+
st, err := openStoreAndInit()
163+
if err != nil {
164+
return err
165+
}
166+
defer func() { _ = st.Close() }()
167+
168+
sourceIDs, err := resolveAccountList(st, collectionsAccounts)
169+
if err != nil {
170+
return err
171+
}
172+
173+
if err := st.RemoveSourcesFromCollection(args[0], sourceIDs); err != nil {
174+
return err
175+
}
176+
fmt.Printf("Removed %d source(s) from %q.\n", len(sourceIDs), args[0])
177+
return nil
178+
}
179+
180+
func runCollectionsDelete(_ *cobra.Command, args []string) error {
181+
st, err := openStoreAndInit()
182+
if err != nil {
183+
return err
184+
}
185+
defer func() { _ = st.Close() }()
186+
187+
if err := st.DeleteCollection(args[0]); err != nil {
188+
return err
189+
}
190+
fmt.Printf("Deleted collection %q.\n", args[0])
191+
return nil
192+
}
193+
194+
195+
func resolveAccountList(st *store.Store, accounts string) ([]int64, error) {
196+
if accounts == "" {
197+
return nil, fmt.Errorf("--accounts is required")
198+
}
199+
parts := strings.Split(accounts, ",")
200+
var ids []int64
201+
for _, p := range parts {
202+
p = strings.TrimSpace(p)
203+
if p == "" {
204+
continue
205+
}
206+
// Try as numeric ID first
207+
if id, err := strconv.ParseInt(p, 10, 64); err == nil {
208+
ids = append(ids, id)
209+
continue
210+
}
211+
// Resolve by identifier
212+
scope, err := ResolveAccount(st, p)
213+
if err != nil {
214+
return nil, err
215+
}
216+
ids = append(ids, scope.SourceIDs()...)
217+
}
218+
if len(ids) == 0 {
219+
return nil, fmt.Errorf("no valid accounts in --accounts")
220+
}
221+
return ids, nil
222+
}
223+
224+
func init() {
225+
rootCmd.AddCommand(collectionsCmd)
226+
collectionsCmd.AddCommand(collectionsCreateCmd)
227+
collectionsCmd.AddCommand(collectionsListCmd)
228+
collectionsCmd.AddCommand(collectionsShowCmd)
229+
collectionsCmd.AddCommand(collectionsAddCmd)
230+
collectionsCmd.AddCommand(collectionsRemoveCmd)
231+
collectionsCmd.AddCommand(collectionsDeleteCmd)
232+
233+
collectionsCreateCmd.Flags().StringVar(&collectionsAccounts,
234+
"accounts", "", "Comma-separated account emails or source IDs")
235+
collectionsAddCmd.Flags().StringVar(&collectionsAccounts,
236+
"accounts", "", "Comma-separated account emails or source IDs")
237+
collectionsRemoveCmd.Flags().StringVar(&collectionsAccounts,
238+
"accounts", "", "Comma-separated account emails or source IDs")
239+
}

cmd/msgvault/cmd/deduplicate.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,13 @@ var (
4949
)
5050

5151
func runDeduplicate(cmd *cobra.Command, _ []string) error {
52-
dbPath := cfg.DatabaseDSN()
53-
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
54-
return fmt.Errorf(
55-
"database not found: %s\nRun 'msgvault init-db' first",
56-
dbPath,
57-
)
58-
}
59-
60-
st, err := store.Open(dbPath)
52+
st, err := openStoreAndInit()
6153
if err != nil {
62-
return fmt.Errorf("open database: %w", err)
54+
return err
6355
}
6456
defer func() { _ = st.Close() }()
6557

66-
if err := st.InitSchema(); err != nil {
67-
return fmt.Errorf("init schema: %w", err)
68-
}
58+
dbPath := cfg.DatabaseDSN()
6959

7060
deletionsDir := filepath.Join(cfg.Data.DataDir, "deletions")
7161

@@ -132,7 +122,7 @@ func runDeduplicate(cmd *cobra.Command, _ []string) error {
132122
return runDeduplicatePerSource(cmd, st, config)
133123
}
134124

135-
return runDeduplicateOnce(cmd, st, dbPath, config, engine)
125+
return runDeduplicateOnce(cmd, dbPath, config, engine)
136126
}
137127

138128
func runDeduplicatePerSource(
@@ -219,7 +209,6 @@ func runDeduplicatePerSource(
219209

220210
func runDeduplicateOnce(
221211
cmd *cobra.Command,
222-
_ *store.Store,
223212
dbPath string,
224213
cfgScoped dedup.Config,
225214
engine *dedup.Engine,

cmd/msgvault/cmd/import_imessage.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ func runImportImessage(cmd *cobra.Command, _ []string) error {
120120

121121
func openStoreAndInit() (*store.Store, error) {
122122
dbPath := cfg.DatabaseDSN()
123+
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
124+
return nil, fmt.Errorf(
125+
"database not found: %s\nRun 'msgvault init-db' first",
126+
dbPath,
127+
)
128+
}
123129
s, err := store.Open(dbPath)
124130
if err != nil {
125131
return nil, fmt.Errorf("open database: %w", err)

0 commit comments

Comments
 (0)