From e40e2a3245d8bde1274ee8b03f737f4ef363ff16 Mon Sep 17 00:00:00 2001 From: Tim Van Baak Date: Fri, 31 Jan 2025 17:23:41 -0800 Subject: [PATCH] Add channels --- cmd/channel.go | 5 ++- cmd/channelAdd.go | 30 ++++++++++++-- cmd/channelDelete.go | 33 +++++++++++++-- cmd/channelEdit.go | 21 ---------- cmd/channelList.go | 45 ++++++++++++++++++++ core/channel.go | 41 +++++++++++++++++++ core/channel_test.go | 70 ++++++++++++++++++++++++++++++++ core/items.go | 25 ++++++++++++ core/sql/0001_initial_schema.sql | 6 +++ 9 files changed, 247 insertions(+), 29 deletions(-) delete mode 100644 cmd/channelEdit.go create mode 100644 cmd/channelList.go create mode 100644 core/channel.go create mode 100644 core/channel_test.go diff --git a/cmd/channel.go b/cmd/channel.go index 2557149..39ab05e 100644 --- a/cmd/channel.go +++ b/cmd/channel.go @@ -7,7 +7,10 @@ import ( var channelCmd = &cobra.Command{ Use: "channel", Short: "Manage channels", - Long: ` + Long: `Manage channels. + +A channel is a group of sources that can be viewed together. Adding a source +to a channel creates it and removing all sources from a channel deletes it. `, } diff --git a/cmd/channelAdd.go b/cmd/channelAdd.go index 5d3ee18..86f7995 100644 --- a/cmd/channelAdd.go +++ b/cmd/channelAdd.go @@ -3,19 +3,43 @@ package cmd import ( "log" + "github.com/Jaculabilis/intake/core" "github.com/spf13/cobra" ) var channelAddCmd = &cobra.Command{ Use: "add", - Short: "Create a channel", - Long: ` + Short: "Add a source to a channel", + Long: `Add a source to a channel. `, Run: func(cmd *cobra.Command, args []string) { - log.Fatal("not implemented") + channelAdd(stringArg(cmd, "channel"), stringArg(cmd, "source")) }, } func init() { channelCmd.AddCommand(channelAddCmd) + + channelAddCmd.Flags().StringP("channel", "c", "", "Channel name") + channelAddCmd.MarkFlagRequired("channel") + + channelAddCmd.Flags().StringP("source", "s", "", "Source to add") + channelAddCmd.MarkFlagRequired("source") +} + +func channelAdd(channel string, source string) { + if channel == "" { + log.Fatal("error: --channel is empty") + } + if source == "" { + log.Fatal("error: --source is empty") + } + + db := openAndMigrateDb() + + if err := core.AddSourceToChannel(db, channel, source); err != nil { + log.Fatalf("error: failed to add source to channel: %v", err) + } + + log.Printf("Added source %s to channel %s", source, channel) } diff --git a/cmd/channelDelete.go b/cmd/channelDelete.go index f2a21bb..102822c 100644 --- a/cmd/channelDelete.go +++ b/cmd/channelDelete.go @@ -3,19 +3,44 @@ package cmd import ( "log" + "github.com/Jaculabilis/intake/core" "github.com/spf13/cobra" ) var channelDeleteCmd = &cobra.Command{ - Use: "delete", - Short: "Delete a channel", - Long: ` + Use: "remove", + Aliases: []string{"rm"}, + Short: "Remove a source from a channel", + Long: `Remove a source from a channel. `, Run: func(cmd *cobra.Command, args []string) { - log.Fatal("not implemented") + channelRemove(stringArg(cmd, "channel"), stringArg(cmd, "source")) }, } func init() { channelCmd.AddCommand(channelDeleteCmd) + + channelDeleteCmd.Flags().StringP("channel", "c", "", "Channel name") + channelDeleteCmd.MarkFlagRequired("channel") + + channelDeleteCmd.Flags().StringP("source", "s", "", "Source to add") + channelDeleteCmd.MarkFlagRequired("source") +} + +func channelRemove(channel string, source string) { + if channel == "" { + log.Fatal("error: --channel is empty") + } + if source == "" { + log.Fatal("error: --source is empty") + } + + db := openAndMigrateDb() + + if err := core.DeleteSourceFromChannel(db, channel, source); err != nil { + log.Fatalf("error: failed to remove source from channel: %v", err) + } + + log.Printf("Removed source %s from channel %s", source, channel) } diff --git a/cmd/channelEdit.go b/cmd/channelEdit.go deleted file mode 100644 index 40a7080..0000000 --- a/cmd/channelEdit.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/spf13/cobra" -) - -var channelEditCmd = &cobra.Command{ - Use: "edit", - Short: "Edit a channel", - Long: ` -`, - Run: func(cmd *cobra.Command, args []string) { - log.Fatal("not implemented") - }, -} - -func init() { - channelCmd.AddCommand(channelEditCmd) -} diff --git a/cmd/channelList.go b/cmd/channelList.go new file mode 100644 index 0000000..f211a93 --- /dev/null +++ b/cmd/channelList.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "log" + + "github.com/Jaculabilis/intake/core" + "github.com/spf13/cobra" +) + +var channelListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List channels", + Long: `List channels. +`, + Run: func(cmd *cobra.Command, args []string) { + channelList(stringArg(cmd, "channel")) + }, +} + +func init() { + channelCmd.AddCommand(channelListCmd) + + channelListCmd.Flags().StringP("channel", "c", "", "List sources in a specific channel") +} + +func channelList(channel string) { + db := openAndMigrateDb() + + channelSources, err := core.GetSourcesInChannel(db) + if err != nil { + log.Fatalf("error: failed to get sources in channel: %v", err) + } + + if channel == "" { + for channel := range channelSources { + fmt.Println(channel) + } + } else { + for _, source := range channelSources[channel] { + fmt.Println(source) + } + } +} diff --git a/core/channel.go b/core/channel.go new file mode 100644 index 0000000..1fc0310 --- /dev/null +++ b/core/channel.go @@ -0,0 +1,41 @@ +package core + +func AddSourceToChannel(db DB, channel string, source string) error { + _, err := db.Exec(` + insert into channels (name, source) + values (?, ?) + `, channel, source) + return err +} + +func DeleteSourceFromChannel(db DB, channel string, source string) error { + _, err := db.Exec(` + delete from channels + where name = ? and source = ? + `, channel, source) + return err +} + +func GetSourcesInChannel(db DB) (map[string][]string, error) { + rows, err := db.Query(` + select name, source + from channels + order by name, source + `) + if err != nil { + return nil, err + } + defer rows.Close() + channelSources := make(map[string][]string) + for rows.Next() { + var name, source string + if err := rows.Scan(&name, &source); err != nil { + return nil, err + } + channelSources[name] = append(channelSources[name], source) + } + if err := rows.Err(); err != nil { + return nil, err + } + return channelSources, nil +} diff --git a/core/channel_test.go b/core/channel_test.go new file mode 100644 index 0000000..c9bc309 --- /dev/null +++ b/core/channel_test.go @@ -0,0 +1,70 @@ +package core + +import "testing" + +func TestChannel(t *testing.T) { + db := EphemeralDb(t) + if err := AddSource(db, "one"); err != nil { + t.Fatalf("failed to add source: %v", err) + } + if err := AddSource(db, "two"); err != nil { + t.Fatalf("failed to add source: %v", err) + } + + // Add sources to channel + if err := AddSourceToChannel(db, "channel", "one"); err != nil { + t.Fatalf("failed to add source to channel: %v", err) + } + if err := AddSourceToChannel(db, "channel", "two"); err != nil { + t.Fatalf("failed to add source to channel: %v", err) + } + + // Both sources are in the channel + sources, err := GetSourcesInChannel(db) + if err != nil { + t.Fatalf("failed to get sources in channel: %v", err) + } + if len(sources["channel"]) != 2 || sources["channel"][0] != "one" || sources["channel"][1] != "two" { + t.Fatalf("expected two sources, got %d: %v", len(sources), sources) + } + + // Get sources in channel after deletion + if err := DeleteSourceFromChannel(db, "channel", "one"); err != nil { + t.Fatalf("failed to delete source from channel: %v", err) + } + sources, err = GetSourcesInChannel(db) + if err != nil { + t.Fatalf("failed to get sources in channel: %v", err) + } + if len(sources) != 1 || sources["channel"][0] != "two" { + t.Fatalf("unexpected sources in channel after deletion: %v", sources) + } + if err := AddSourceToChannel(db, "channel", "one"); err != nil { + t.Fatalf("failed to add source to channel: %v", err) + } + + // Items on both sources appear in the channel + if err := AddItems(db, []Item{ + {"one", "a", 0, true, "", "", "", "", 0, nil}, + {"two", "b", 0, true, "", "", "", "", 0, nil}, + }); err != nil { + t.Fatalf("failed to add items to one: %v", err) + } + if _, err := DeactivateItem(db, "one", "a"); err != nil { + t.Fatalf("failed to deactivate item: %v", err) + } + items, err := GetAllItemsForChannel(db, "channel") + if err != nil { + t.Fatalf("failed to get all items in channel: %v", err) + } + if len(items) != 2 || items[0].Id != "a" || items[1].Id != "b" { + t.Fatalf("expected two items, got %d: %v", len(items), items) + } + items, err = GetActiveItemsForChannel(db, "channel") + if err != nil { + t.Fatalf("failed to get all items in channel: %v", err) + } + if len(items) != 1 || items[0].Id != "b" { + t.Fatalf("expected one item, got %d: %v", len(items), items) + } +} diff --git a/core/items.go b/core/items.go index de5b3a5..de5051e 100644 --- a/core/items.go +++ b/core/items.go @@ -202,3 +202,28 @@ func GetAllItemsForSource(db DB, source string) ([]Item, error) { order by case when time = 0 then created else time end, id `, source) } + +func GetActiveItemsForChannel(db DB, channel string) ([]Item, error) { + return getItems(db, ` + select + i.source, i.id, i.created, i.active, i.title, i.author, i.body, i.link, i.time, json(i.action) + from items i + join channels c on i.source = c.source + where + c.name = ? + and i.active <> 0 + order by case when i.time = 0 then i.created else i.time end, i.id + `, channel) +} + +func GetAllItemsForChannel(db DB, channel string) ([]Item, error) { + return getItems(db, ` + select + i.source, i.id, i.created, i.active, i.title, i.author, i.body, i.link, i.time, json(i.action) + from items i + join channels c on i.source = c.source + where + c.name = ? + order by case when i.time = 0 then i.created else i.time end, i.id + `, channel) +} diff --git a/core/sql/0001_initial_schema.sql b/core/sql/0001_initial_schema.sql index 1c3dab5..0493080 100644 --- a/core/sql/0001_initial_schema.sql +++ b/core/sql/0001_initial_schema.sql @@ -31,3 +31,9 @@ create table items( primary key (source, id), foreign key (source) references sources (name) on delete cascade ) strict; +create table channels( + name text not null, + source text not null, + unique (name, source) on conflict replace + foreign key (source) references sources (name) on delete cascade +)