Compare commits

...

12 Commits

Author SHA1 Message Date
7bea8c247a Update todo list 2025-01-29 22:42:11 -08:00
647584e55b Order items by .time or .created 2025-01-29 22:41:50 -08:00
c4d53eb993 Hook up the Deactivate All button 2025-01-29 22:21:17 -08:00
3519517b96 Disable action button while the action runs 2025-01-29 19:34:55 -08:00
7477504508 Swap outerHTML by default 2025-01-29 19:34:25 -08:00
3118758f1d Update todo list 2025-01-29 14:56:02 -08:00
6ef51b7286 Execute actions from web UI 2025-01-29 14:54:29 -08:00
680d8db6bb action execute respects item action support 2025-01-29 09:13:48 -08:00
7ca6ccfaf3 Rename variables 2025-01-29 09:07:57 -08:00
f804299180 Add actions to items 2025-01-29 08:52:39 -08:00
d23efdf00b Check Item equality with a function
Using == won't work when the Action field is a map[string]RawMessage
2025-01-29 08:52:39 -08:00
453bc9d601 Replace AddItem with AddItems 2025-01-29 07:43:06 -08:00
20 changed files with 369 additions and 113 deletions

View File

@ -1,7 +1,10 @@
.PHONY: help serve .PHONY: help serve test-data
help: ## display this help help: ## display this help
@awk 'BEGIN{FS = ":.*##"; printf "\033[1m\nUsage\n \033[1;92m make\033[0;36m <target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST) @awk 'BEGIN{FS = ":.*##"; printf "\033[1m\nUsage\n \033[1;92m make\033[0;36m <target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST)
serve: ## Run "intake serve" with live reload serve: ## Run "intake serve" with live reload
@air -build.cmd "go build -o tmp/intake" -build.bin tmp/intake -build.args_bin serve,--data-dir,tmp @air -build.cmd "go build -o tmp/intake" -build.bin tmp/intake -build.args_bin serve,--data-dir,tmp
test-data: ## Recreate test data in tmp/
@test/test_items.sh

View File

@ -23,14 +23,14 @@ Parity with existing Python version
* [ ] rename * [ ] rename
* [x] delete * [x] delete
* [x] execute * [x] execute
* [ ] require items to declare action support * [x] require items to declare action support
* [ ] state files * [ ] state files
* [ ] source environment * [ ] source environment
* [ ] working directory set * [ ] working directory set
* [ ] update web UI credentials * [ ] update web UI credentials
* [ ] automatic crontab integration * [ ] automatic crontab integration
* [ ] feed supports item TTS * [ ] feed supports item TTS
* [ ] data directory from envvars * [x] data directory from envvars
* [ ] source-level tt{s,d,l} * [ ] source-level tt{s,d,l}
* [ ] source batching * [ ] source batching
* channels * channels
@ -41,12 +41,14 @@ Parity with existing Python version
* feeds * feeds
* [x] show items * [x] show items
* [x] deactivate items * [x] deactivate items
* [ ] mass deactivate * [x] mass deactivate
* [ ] punt * [ ] punt
* [ ] trigger actions * [x] trigger actions
* [x] add ad-hoc items * [x] add ad-hoc items
* [ ] show/hide deactivated items * [ ] show/hide deactivated items
* [ ] show/hide tts items * [ ] show/hide tts items
* [x] sort by time ?? created
* [ ] paging
* [ ] NixOS module * [ ] NixOS module
* [ ] NixOS module demo * [ ] NixOS module demo
@ -92,6 +94,7 @@ Any unspecified field is equivalent to the empty string, object, or 0, depending
| `body` | Optional | Body text of the item as raw HTML. This will be displayed in the item without further processing! Consider your sources' threat models against injection attacks. | `body` | Optional | Body text of the item as raw HTML. This will be displayed in the item without further processing! Consider your sources' threat models against injection attacks.
| `link` | Optional | A hyperlink associated with the item. | `link` | Optional | A hyperlink associated with the item.
| `time` | Optional | A Unix timestamp associated with the item, not necessarily when the item was created. Items sort by `time` when it is defined and fall back to `created`. Displayed in the item footer. | `time` | Optional | A Unix timestamp associated with the item, not necessarily when the item was created. Items sort by `time` when it is defined and fall back to `created`. Displayed in the item footer.
| `action` | Optional | A JSON object with keys for all supported actions. No schema is imposed on the values.
Existing items are updated with new values when a fetch or action produces them, with some exceptions: Existing items are updated with new values when a fetch or action produces them, with some exceptions:

View File

@ -16,6 +16,9 @@ var actionExecuteCmd = &cobra.Command{
Short: "Run a source action for an item", Short: "Run a source action for an item",
Long: fmt.Sprintf(`Execute a source action for an item. Long: fmt.Sprintf(`Execute a source action for an item.
The item must declare support for the action by having the action's name
in its "action" field. Use --force to execute the action anyway.
The "fetch" action is special and does not execute for any specific item. The "fetch" action is special and does not execute for any specific item.
Use "intake source fetch" to run the fetch action. Use "intake source fetch" to run the fetch action.
@ -33,6 +36,7 @@ var actionExecuteItem string
var actionExecuteFormat string var actionExecuteFormat string
var actionExecuteDryRun bool var actionExecuteDryRun bool
var actionExecuteDiff bool var actionExecuteDiff bool
var actionExecuteForce bool
func init() { func init() {
actionCmd.AddCommand(actionExecuteCmd) actionCmd.AddCommand(actionExecuteCmd)
@ -46,10 +50,12 @@ func init() {
actionExecuteCmd.PersistentFlags().StringVarP(&actionExecuteAction, "action", "a", "", "Action to run") actionExecuteCmd.PersistentFlags().StringVarP(&actionExecuteAction, "action", "a", "", "Action to run")
actionExecuteCmd.MarkFlagRequired("action") actionExecuteCmd.MarkFlagRequired("action")
actionExecuteCmd.Flags().StringVarP(&actionExecuteFormat, "format", "f", "headlines", "Feed format for returned items.") actionExecuteCmd.Flags().StringVarP(&actionExecuteFormat, "format", "f", "headlines", "Feed format for returned items")
actionExecuteCmd.Flags().BoolVar(&actionExecuteDryRun, "dry-run", false, "Instead of updating the item, print it") actionExecuteCmd.Flags().BoolVar(&actionExecuteDryRun, "dry-run", false, "Instead of updating the item, print it")
actionExecuteCmd.Flags().BoolVar(&actionExecuteDiff, "diff", false, "Show which fields of the item changed") actionExecuteCmd.Flags().BoolVar(&actionExecuteDiff, "diff", false, "Show which fields of the item changed")
actionExecuteCmd.Flags().BoolVar(&actionExecuteForce, "force", false, "Execute the action even if the item does not support it")
} }
func actionExecute() { func actionExecute() {
@ -67,16 +73,24 @@ func actionExecute() {
db := openAndMigrateDb() db := openAndMigrateDb()
argv, err := core.GetArgvForAction(db, actionExecuteSource, actionExecuteAction)
if err != nil {
log.Fatalf("error: failed to get action: %v", err)
}
item, err := core.GetItem(db, actionExecuteSource, actionExecuteItem) item, err := core.GetItem(db, actionExecuteSource, actionExecuteItem)
if err != nil { if err != nil {
log.Fatalf("error: failed to get item: %v", err) log.Fatalf("error: failed to get item: %v", err)
} }
if item.Action[actionExecuteAction] == nil {
if actionExecuteForce {
log.Printf("warning: force-executing %s on %s/%s", actionExecuteAction, actionExecuteSource, actionExecuteItem)
} else {
log.Fatalf("error: %s/%s does not support %s", actionExecuteSource, actionExecuteItem, actionExecuteAction)
}
}
argv, err := core.GetArgvForAction(db, actionExecuteSource, actionExecuteAction)
if err != nil {
log.Fatalf("error: failed to get action: %v", err)
}
itemJson, err := json.Marshal(item) itemJson, err := json.Marshal(item)
if err != nil { if err != nil {
log.Fatalf("error: failed to serialize item: %v", err) log.Fatalf("error: failed to serialize item: %v", err)
@ -108,7 +122,7 @@ func actionExecute() {
if item.Time != newItem.Time { if item.Time != newItem.Time {
log.Printf("time: %d => %d", item.Time, newItem.Time) log.Printf("time: %d => %d", item.Time, newItem.Time)
} }
if item == newItem { if core.ItemsAreEqual(item, newItem) {
log.Printf("no changes\n") log.Printf("no changes\n")
} }
} }

View File

@ -49,7 +49,7 @@ func feed() {
items, err = core.GetActiveItemsForSource(db, feedSource) items, err = core.GetActiveItemsForSource(db, feedSource)
} }
if err != nil { if err != nil {
log.Fatalf("error: failed to fetch items from %s", feedSource) log.Fatalf("error: failed to fetch items from %s:, %v", feedSource, err)
} }
} else if feedChannel != "" { } else if feedChannel != "" {
log.Fatal("error: unimplemented") log.Fatal("error: unimplemented")
@ -60,7 +60,7 @@ func feed() {
items, err = core.GetAllActiveItems(db) items, err = core.GetAllActiveItems(db)
} }
if err != nil { if err != nil {
log.Fatal("error: failed to fetch items") log.Fatalf("error: failed to fetch items: %v", err)
} }
} }

View File

@ -3,7 +3,7 @@ package cmd
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "encoding/json"
"log" "log"
"github.com/Jaculabilis/intake/core" "github.com/Jaculabilis/intake/core"
@ -23,46 +23,63 @@ if it doesn't exist, with a random id.`,
}, },
} }
var addSource string var addItemSource string
var addId string var addItemId string
var addTitle string var addItemTitle string
var addAuthor string var addItemAuthor string
var addBody string var addItemBody string
var addLink string var addItemLink string
var addTime int var addItemTime int
var addItemActions string
func init() { func init() {
itemCmd.AddCommand(itemAddCmd) itemCmd.AddCommand(itemAddCmd)
itemAddCmd.Flags().StringVarP(&addSource, "source", "s", "", "Source in which to create the item (default: default)") itemAddCmd.Flags().StringVarP(&addItemSource, "source", "s", "", "Source in which to create the item (default: default)")
itemAddCmd.Flags().StringVarP(&addId, "id", "i", "", "Item id (default: random hex)") itemAddCmd.Flags().StringVarP(&addItemId, "id", "i", "", "Item id (default: random hex)")
itemAddCmd.Flags().StringVarP(&addTitle, "title", "t", "", "Item title") itemAddCmd.Flags().StringVarP(&addItemTitle, "title", "t", "", "Item title")
itemAddCmd.Flags().StringVarP(&addAuthor, "author", "a", "", "Item author") itemAddCmd.Flags().StringVarP(&addItemAuthor, "author", "a", "", "Item author")
itemAddCmd.Flags().StringVarP(&addBody, "body", "b", "", "Item body") itemAddCmd.Flags().StringVarP(&addItemBody, "body", "b", "", "Item body")
itemAddCmd.Flags().StringVarP(&addLink, "link", "l", "", "Item link") itemAddCmd.Flags().StringVarP(&addItemLink, "link", "l", "", "Item link")
itemAddCmd.Flags().IntVarP(&addTime, "time", "m", 0, "Item time as a Unix timestamp") itemAddCmd.Flags().IntVarP(&addItemTime, "time", "m", 0, "Item time as a Unix timestamp")
itemAddCmd.Flags().StringVarP(&addItemActions, "action", "x", "", "Item time as a Unix timestamp")
} }
func itemAdd() { func itemAdd() {
// Default to "default" source // Default to "default" source
if addSource == "" { if addItemSource == "" {
addSource = "default" addItemSource = "default"
} }
// Default id to random hex string // Default id to random hex string
if addId == "" { if addItemId == "" {
bytes := make([]byte, 16) bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
log.Fatal("Failed to generate id") log.Fatalf("error: failed to generate id: %v", err)
}
addItemId = hex.EncodeToString(bytes)
}
var actions core.Actions
if addItemActions != "" {
if err := json.Unmarshal([]byte(addItemActions), &actions); err != nil {
log.Fatalf("error: could not parse actions: %v", err)
} }
addId = hex.EncodeToString(bytes)
} }
db := openAndMigrateDb() db := openAndMigrateDb()
err := core.AddItem(db, addSource, addId, addTitle, addAuthor, addBody, addLink, addTime) if err := core.AddItems(db, []core.Item{{
if err != nil { Source: addItemSource,
log.Fatalf("Failed to add item: %s", err) Id: addItemId,
Title: addItemTitle,
Author: addItemAuthor,
Body: addItemBody,
Link: addItemLink,
Time: addItemTime,
Action: actions,
}}); err != nil {
log.Fatalf("error: failed to add item: %s", err)
} }
fmt.Printf("Added %s/%s\n", addSource, addId) log.Printf("Added %s/%s\n", addItemSource, addItemId)
} }

View File

@ -25,20 +25,20 @@ var sourceTestCmd = &cobra.Command{
}, },
} }
var testEnv []string var sourceTestEnv []string
var testFormat string var sourceTestFormat string
func init() { func init() {
sourceCmd.AddCommand(sourceTestCmd) sourceCmd.AddCommand(sourceTestCmd)
sourceTestCmd.Flags().StringArrayVarP(&testEnv, "env", "e", nil, "Environment variables to set, in the form KEY=VAL") sourceTestCmd.Flags().StringArrayVarP(&sourceTestEnv, "env", "e", nil, "Environment variables to set, in the form KEY=VAL")
sourceTestCmd.Flags().StringVarP(&testFormat, "format", "f", "headlines", "Feed format for returned items.") sourceTestCmd.Flags().StringVarP(&sourceTestFormat, "format", "f", "headlines", "Feed format for returned items.")
} }
func sourceTest(cmd []string) { func sourceTest(cmd []string) {
formatter := formatAs(testFormat) formatter := formatAs(sourceTestFormat)
items, err := core.Execute("", cmd, testEnv, "", time.Minute) items, err := core.Execute("", cmd, sourceTestEnv, "", time.Minute)
log.Printf("Returned %d items", len(items)) log.Printf("Returned %d items", len(items))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)

View File

@ -147,4 +147,36 @@ func TestExecute(t *testing.T) {
res, err = execute([]string{"jq", "-cn", `["a", "a"] | .[] | {id: .}`}) res, err = execute([]string{"jq", "-cn", `["a", "a"] | .[] | {id: .}`})
assertNil(err) assertNil(err)
assertLen(res, 2) assertLen(res, 2)
// Action keys are detected even with empty values
res, err = execute([]string{"jq", "-cn", `{id: "test", action: {"hello": null}}`})
assertNil(err)
assertLen(res, 1)
if res[0].Action["hello"] == nil {
t.Fatal("missing hello action")
}
if res[0].Action["goodbye"] != nil {
t.Fatal("nonexistent action should key to nil in Action")
}
res, err = execute([]string{"jq", "-cn", `{id: "test", action: {"hello": ""}}`})
assertNil(err)
assertLen(res, 1)
if res[0].Action["hello"] == nil {
t.Fatal("missing hello action")
}
res, err = execute([]string{"jq", "-cn", `{id: "test", action: {"hello": []}}`})
assertNil(err)
assertLen(res, 1)
if res[0].Action["hello"] == nil {
t.Fatal("missing hello action")
}
res, err = execute([]string{"jq", "-cn", `{id: "test", action: {"hello": {}}}`})
assertNil(err)
assertLen(res, 1)
if res[0].Action["hello"] == nil {
t.Fatal("missing hello action")
}
} }

View File

@ -11,21 +11,21 @@ func TestDeleteSourceCascade(t *testing.T) {
db := EphemeralDb(t) db := EphemeralDb(t)
if err := AddSource(db, "source1"); err != nil { if err := AddSource(db, "source1"); err != nil {
t.Fatal(err) t.Fatalf("failed to add source1: %v", err)
} }
if err := AddSource(db, "source2"); err != nil { if err := AddSource(db, "source2"); err != nil {
t.Fatal(err) t.Fatalf("failed to add source2: %v", err)
} }
if err := AddItem(db, "source1", "item1", "", "", "", "", 0); err != nil { if err := AddItems(db, []Item{
t.Fatal(err) {"source1", "item1", 0, true, "", "", "", "", 0, nil},
} {"source2", "item2", 0, true, "", "", "", "", 0, nil},
if err := AddItem(db, "source2", "item2", "", "", "", "", 0); err != nil { }); err != nil {
t.Fatal(err) t.Fatalf("failed to add items: %v", err)
} }
items, err := GetAllActiveItems(db) items, err := GetAllActiveItems(db)
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("failed to get active items: %v", err)
} }
if len(items) != 2 { if len(items) != 2 {
t.Fatal("Expected 2 items") t.Fatal("Expected 2 items")
@ -39,10 +39,10 @@ func TestDeleteSourceCascade(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 1 { if len(items) != 1 {
t.Fatal("Expected only 1 item after source delete") t.Fatalf("Expected only 1 item after source delete, got %d", len(items))
} }
err = AddItem(db, "source1", "item3", "", "", "", "", 0) err = AddItems(db, []Item{{"source1", "item3", 0, true, "", "", "", "", 0, nil}})
if err == nil { if err == nil {
t.Fatal("Unexpected success adding item for nonexistent source") t.Fatal("Unexpected success adding item for nonexistent source")
} }

View File

@ -1,21 +1,33 @@
package core package core
import ( import (
"database/sql/driver"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
) )
type Actions map[string]json.RawMessage
func (a Actions) Value() (driver.Value, error) {
return json.Marshal(a)
}
func (a *Actions) Scan(value interface{}) error {
return json.Unmarshal([]byte(value.(string)), a)
}
type Item struct { type Item struct {
Source string `json:"source"` Source string `json:"source"`
Id string `json:"id"` Id string `json:"id"`
Created int `json:"created"` Created int `json:"created"`
Active bool `json:"active"` Active bool `json:"active"`
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
Body string `json:"body"` Body string `json:"body"`
Link string `json:"link"` Link string `json:"link"`
Time int `json:"time"` Time int `json:"time"`
Action Actions `json:"action"`
} }
// Whether an item that no longer appears in a fetch can be deleted. // Whether an item that no longer appears in a fetch can be deleted.
@ -23,6 +35,11 @@ func (item Item) Deletable() bool {
return !item.Active return !item.Active
} }
func ItemsAreEqual(first Item, second Item) bool {
// Hacky but easy to use
return fmt.Sprintf("%#v", first) == fmt.Sprintf("%#v", second)
}
func FormatAsHeadline(item Item) string { func FormatAsHeadline(item Item) string {
title := item.Title title := item.Title
if title == "" { if title == "" {

51
core/item_test.go Normal file
View File

@ -0,0 +1,51 @@
package core
import (
"encoding/json"
"testing"
)
func TestItemFormatsExist(t *testing.T) {
for name := range AvailableFormats {
formatter, err := FormatAs(name)
if err != nil {
t.Fatalf("error getting formatter for available format %s: %v", name, err)
}
if formatter == nil {
t.Fatalf("formatter %s is nil", name)
}
}
}
func TestItemRoundTrip(t *testing.T) {
db := EphemeralDb(t)
if err := AddSource(db, "_"); err != nil {
t.Fatalf("failed to create source: %v", err)
}
item1 := Item{
Source: "_",
Id: "a",
Created: 0,
Active: true,
Title: "title",
Author: "author",
Body: "body",
Link: "link",
Time: 123456,
Action: map[string]json.RawMessage{
"hello": json.RawMessage(`"world"`),
},
}
if err := AddItems(db, []Item{item1}); err != nil {
t.Fatalf("update failed: %v", err)
}
item2, err := GetItem(db, item1.Source, item1.Id)
if err != nil {
t.Fatalf("could not get item: %v", err)
}
item2.Created = 0 // automatically set by db
if !ItemsAreEqual(item1, item2) {
t.Fatalf("items are not equal, err %v", err)
}
}

View File

@ -48,37 +48,23 @@ func DeleteSource(db *DB, name string) error {
return err return err
} }
func AddItem(
db *DB,
source string,
id string,
title string,
author string,
body string,
link string,
time int,
) error {
_, err := db.Exec(`
insert into items (source, id, active, title, author, body, link, time)
values (?, ?, ?, ?, ?, ?, ?, ?)
`, source, id, true, title, author, body, link, time)
return err
}
func AddItems(db *DB, items []Item) error { func AddItems(db *DB, items []Item) error {
return db.Transact(func(tx *sql.Tx) error { return db.Transact(func(tx *sql.Tx) error {
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
insert into items (source, id, active, title, author, body, link, time) insert into items (source, id, active, title, author, body, link, time, action)
values (?, ?, ?, ?, ?, ?, ?, ?) values (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?))
`) `)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to prepare insert: %v", err)
} }
for _, item := range items { for _, item := range items {
_, err = stmt.Exec(item.Source, item.Id, true, item.Title, item.Author, item.Body, item.Link, item.Time) actions, err := json.Marshal(item.Action)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to marshal actions for %s/%s: %v", item.Source, item.Id, err)
}
_, err = stmt.Exec(item.Source, item.Id, true, item.Title, item.Author, item.Body, item.Link, item.Time, actions)
if err != nil {
return fmt.Errorf("failed to insert %s/%s: %v", item.Source, item.Id, err)
} }
} }
return nil return nil
@ -118,7 +104,8 @@ func UpdateItems(db *DB, items []Item) error {
author = ?, author = ?,
body = ?, body = ?,
link = ?, link = ?,
time = ? time = ?,
action = jsonb(?)
where source = ? where source = ?
and id = ? and id = ?
`) `)
@ -126,7 +113,11 @@ func UpdateItems(db *DB, items []Item) error {
return err return err
} }
for _, item := range items { for _, item := range items {
_, err = stmt.Exec(item.Title, item.Author, item.Body, item.Link, item.Time, item.Source, item.Id) actions, err := json.Marshal(item.Action)
if err != nil {
return fmt.Errorf("failed to marshal actions for %s/%s: %v", item.Source, item.Id, err)
}
_, err = stmt.Exec(item.Title, item.Author, item.Body, item.Link, item.Time, actions, item.Source, item.Id)
if err != nil { if err != nil {
return err return err
} }
@ -179,7 +170,7 @@ func getItems(db *DB, query string, args ...any) ([]Item, error) {
var items []Item var items []Item
for rows.Next() { for rows.Next() {
var item Item var item Item
err = rows.Scan(&item.Source, &item.Id, &item.Created, &item.Active, &item.Title, &item.Author, &item.Body, &item.Link, &item.Time) err = rows.Scan(&item.Source, &item.Id, &item.Created, &item.Active, &item.Title, &item.Author, &item.Body, &item.Link, &item.Time, &item.Action)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -190,10 +181,11 @@ func getItems(db *DB, query string, args ...any) ([]Item, error) {
func GetItem(db *DB, source string, id string) (Item, error) { func GetItem(db *DB, source string, id string) (Item, error) {
items, err := getItems(db, ` items, err := getItems(db, `
select source, id, created, active, title, author, body, link, time select source, id, created, active, title, author, body, link, time, json(action)
from items from items
where source = ? where source = ?
and id = ? and id = ?
order by case when time = 0 then created else time end, id
`, source, id) `, source, id)
if err != nil { if err != nil {
return Item{}, err return Item{}, err
@ -207,42 +199,48 @@ func GetItem(db *DB, source string, id string) (Item, error) {
func GetAllActiveItems(db *DB) ([]Item, error) { func GetAllActiveItems(db *DB) ([]Item, error) {
return getItems(db, ` return getItems(db, `
select select
source, id, created, active, title, author, body, link, time source, id, created, active, title, author, body, link, time, json(action)
from items from items
where active <> 0 where active <> 0
order by case when time = 0 then created else time end, id
`) `)
} }
func GetAllItems(db *DB) ([]Item, error) { func GetAllItems(db *DB) ([]Item, error) {
return getItems(db, ` return getItems(db, `
select select
source, id, created, active, title, author, body, link, time source, id, created, active, title, author, body, link, time, json(action)
from items from items
order by case when time = 0 then created else time end, id
`) `)
} }
func GetActiveItemsForSource(db *DB, source string) ([]Item, error) { func GetActiveItemsForSource(db *DB, source string) ([]Item, error) {
return getItems(db, ` return getItems(db, `
select select
source, id, created, active, title, author, body, link, time source, id, created, active, title, author, body, link, time, json(action)
from items from items
where where
source = ? source = ?
and active <> 0 and active <> 0
order by case when time = 0 then created else time end, id
`, source) `, source)
} }
func GetAllItemsForSource(db *DB, source string) ([]Item, error) { func GetAllItemsForSource(db *DB, source string) ([]Item, error) {
return getItems(db, ` return getItems(db, `
select select
source, id, created, active, title, author, body, link, time source, id, created, active, title, author, body, link, time, json(action)
from items from items
where where
source = ? source = ?
order by case when time = 0 then created else time end, id
`, source) `, source)
} }
// Given the results of a fetch, add new items, update existing items, and delete expired items. // Given the results of a fetch, add new items, update existing items, and delete expired items.
//
// Returns the number of new and deleted items on success.
func UpdateWithFetchedItems(db *DB, source string, items []Item) (int, int, error) { func UpdateWithFetchedItems(db *DB, source string, items []Item) (int, int, error) {
// Get the existing items // Get the existing items
existingItems, err := GetAllItemsForSource(db, source) existingItems, err := GetAllItemsForSource(db, source)

View File

@ -57,24 +57,25 @@ func AssertItemIs(t *testing.T, item Item, expected string) {
func TestAddItem(t *testing.T) { func TestAddItem(t *testing.T) {
db := EphemeralDb(t) db := EphemeralDb(t)
if err := AddSource(db, "test"); err != nil { if err := AddSource(db, "test"); err != nil {
t.Fatal(err) t.Fatalf("failed to add source: %v", err)
} }
if err := AddItem(db, "test", "one", "", "", "", "", 0); err != nil { if err := AddItems(db, []Item{
t.Fatal(err) {"test", "one", 0, true, "", "", "", "", 0, nil},
} {"test", "two", 0, true, "title", "author", "body", "link", 123456, nil},
if err := AddItem(db, "test", "two", "title", "author", "body", "link", 123456); err != nil { }); err != nil {
t.Fatal(err) t.Fatalf("failed to add items: %v", err)
} }
items, err := GetActiveItemsForSource(db, "test") items, err := GetActiveItemsForSource(db, "test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("failed to get active items: %v", err)
} }
if len(items) != 2 { if len(items) != 2 {
t.Fatal("should get two items") t.Fatal("should get two items")
} }
AssertItemIs(t, items[0], "test/one/true/////0") // order is by (time ?? created) so this ordering is correct as long as you don't run it in early 1970
AssertItemIs(t, items[1], "test/two/true/title/author/body/link/123456") AssertItemIs(t, items[0], "test/two/true/title/author/body/link/123456")
AssertItemIs(t, items[1], "test/one/true/////0")
if _, err = DeactivateItem(db, "test", "one"); err != nil { if _, err = DeactivateItem(db, "test", "one"); err != nil {
t.Fatal(err) t.Fatal(err)
@ -205,7 +206,7 @@ func TestOnCreateAction(t *testing.T) {
} }
updated := getItem("one") updated := getItem("one")
updated.Created = 0 // zero out for comparison with pre-insert item updated.Created = 0 // zero out for comparison with pre-insert item
if updated != items[0] { if !ItemsAreEqual(updated, items[0]) {
t.Fatalf("expected no change: %#v != %#v", updated, items[0]) t.Fatalf("expected no change: %#v != %#v", updated, items[0])
} }
@ -219,8 +220,9 @@ func TestOnCreateAction(t *testing.T) {
if add != 1 || err != nil { if add != 1 || err != nil {
t.Fatal("failed update with alter oncreate") t.Fatal("failed update with alter oncreate")
} }
if getItem("two").Title != "Goodbye, World" { two := getItem("two")
t.Fatal("title not updated") if two.Title != "Goodbye, World" {
t.Fatalf("title not updated, is: %s", two.Title)
} }
// on_create can add a field // on_create can add a field

View File

@ -19,6 +19,7 @@ create table items(
body text, body text,
link text, link text,
time int, time int,
action blob,
primary key (source, id), primary key (source, id),
foreign key (source) references sources (name) on delete cascade foreign key (source) references sources (name) on delete cascade
) strict; ) strict;

View File

@ -5,6 +5,7 @@ go build -o tmp/intake
rm tmp/intake.db* || true rm tmp/intake.db* || true
export INTAKE_DATA_DIR="tmp" export INTAKE_DATA_DIR="tmp"
tmp/intake migrate tmp/intake migrate
tmp/intake source add -s feedtest tmp/intake source add -s feedtest
tmp/intake item add -s feedtest --id "this-item-has-no-title" tmp/intake item add -s feedtest --id "this-item-has-no-title"
tmp/intake item add -s feedtest --title "This item has only a title" tmp/intake item add -s feedtest --title "This item has only a title"
@ -18,3 +19,7 @@ tmp/intake item add -s feedtest --title "Title, time" --time 1737780324
tmp/intake item add -s feedtest --title "Title, author, body" --author "Authorname" --body "Hello body!" tmp/intake item add -s feedtest --title "Title, author, body" --author "Authorname" --body "Hello body!"
tmp/intake item add -s feedtest --title "Title, author, time, body" --author "Authorname" --time 1700000000 --body "Hello body!" tmp/intake item add -s feedtest --title "Title, author, time, body" --author "Authorname" --time 1700000000 --body "Hello body!"
tmp/intake item add -s feedtest --title "Title, time, body" --time 1737780324 --body "Hello, body!" tmp/intake item add -s feedtest --title "Title, time, body" --time 1737780324 --body "Hello, body!"
tmp/intake source add -s spook
tmp/intake action add -s spook -a spookier -- jq -c '.title = .title + "o"'
tmp/intake item add -s spook --id boo --title "Boo" --action '{"spookier": true}'

View File

@ -14,7 +14,11 @@
{{ end }} {{ end }}
<article class="center"> <article class="center">
<button>Deactivate All</button> <button
hx-post="/mass-deactivate"
hx-vals='{{ massDeacVars .Items }}'
hx-confirm="Deactivate {{ len .Items }} items?"
>Deactivate All</button>
</article> </article>
{{ else }} {{ else }}

View File

@ -2,8 +2,10 @@ package html
import ( import (
"embed" "embed"
"encoding/json"
"html/template" "html/template"
"io" "io"
"log"
"time" "time"
"github.com/Jaculabilis/intake/core" "github.com/Jaculabilis/intake/core"
@ -18,9 +20,25 @@ func tsToDate(t int) string {
return tm.Format(time.DateTime) return tm.Format(time.DateTime)
} }
func massDeactivateVals(items []core.Item) string {
var shorts []string
for _, item := range items {
shorts = append(shorts, core.FormatAsShort(item))
}
massDeac := struct {
Items []string `json:"items"`
}{shorts}
vals, err := json.Marshal(massDeac)
if err != nil {
log.Printf("error serializing mass deactivate list: %v", err)
}
return string(vals)
}
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"raw": rawHtml, "raw": rawHtml,
"tsToDate": tsToDate, "tsToDate": tsToDate,
"massDeacVars": massDeactivateVals,
} }
//go:embed intake.css //go:embed intake.css

View File

@ -3,7 +3,6 @@
class="item-button" class="item-button"
title="Deactivate {{ .Source }}/{{ .Id }}" title="Deactivate {{ .Source }}/{{ .Id }}"
hx-target="closest article" hx-target="closest article"
hx-swap="outerHTML"
hx-select="article" hx-select="article"
hx-delete="/item/{{ .Source }}/{{ .Id }}" hx-delete="/item/{{ .Source }}/{{ .Id }}"
>&#10005;</button> >&#10005;</button>
@ -13,6 +12,16 @@
>&#8631;</button> >&#8631;</button>
{{- if .Link }}<a class="item-link" href="{{ .Link }}" target="_blank">&#8663;</a> {{- if .Link }}<a class="item-link" href="{{ .Link }}" target="_blank">&#8663;</a>
{{ end -}} {{ end -}}
{{ range $key, $_ := .Action }}
<button
class="item-button"
title="{{ $key }}"
hx-target="closest article"
hx-select="article"
hx-disabled-elt="this"
hx-post="/item/{{ $.Source }}/{{ $.Id }}/action/{{ $key }}"
>{{ $key }}</button>
{{ end -}}
{{ end }} {{ end }}
{{ define "item-title" -}} {{ define "item-title" -}}
@ -34,9 +43,7 @@
{{ template "item-buttons" . }} {{ template "item-buttons" . }}
{{ template "item-title" . }} {{ template "item-title" . }}
</summary> </summary>
{{ if .Body }}
<p>{{ raw .Body }}</p> <p>{{ raw .Body }}</p>
{{ end }}
</details> </details>
{{ template "item-buttons" . }} {{ template "item-buttons" . }}
{{- else -}} {{- else -}}

View File

@ -6,7 +6,7 @@
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwgAADsIBFShKgAAAABh0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMS41ZEdYUgAAAGFJREFUOE+lkFEKwDAIxXrzXXB3ckMm9EnAV/YRCxFCcUXEL3Jc77NDjpDA/VGL3RFWYEICfeGC8oQc9IPuCAnQDcoRVmBCAn3hgvKEHPSD7ggJ0A3KEVZgQgJ94YLSJ9YDUzNGDXGZ/JEAAAAASUVORK5CYII="> <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwgAADsIBFShKgAAAABh0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMS41ZEdYUgAAAGFJREFUOE+lkFEKwDAIxXrzXXB3ckMm9EnAV/YRCxFCcUXEL3Jc77NDjpDA/VGL3RFWYEICfeGC8oQc9IPuCAnQDcoRVmBCAn3hgvKEHPSD7ggJ0A3KEVZgQgJ94YLSJ9YDUzNGDXGZ/JEAAAAASUVORK5CYII=">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<script src="/htmx.org@2.0.4.js"></script> <script src="/htmx.org@2.0.4.js"></script>
<meta name="htmx-config" content='{"ignoreTitle":true}'> <meta name="htmx-config" content='{"ignoreTitle":true,"defaultSwapStyle":"outerHTML"}'>
</head> </head>
<body> <body>
<main> <main>

View File

@ -1,7 +1,11 @@
package web package web
import ( import (
"encoding/json"
"log"
"net/http" "net/http"
"strings"
"time"
"github.com/Jaculabilis/intake/core" "github.com/Jaculabilis/intake/core"
"github.com/Jaculabilis/intake/web/html" "github.com/Jaculabilis/intake/web/html"
@ -35,3 +39,81 @@ func (env *Env) deleteItem(writer http.ResponseWriter, req *http.Request) {
} }
html.Item(writer, html.ItemData{Item: item}) html.Item(writer, html.ItemData{Item: item})
} }
func (env *Env) doAction(writer http.ResponseWriter, req *http.Request) {
source := req.PathValue("source")
id := req.PathValue("id")
action := req.PathValue("action")
item, err := core.GetItem(env.db, source, id)
if err != nil {
http.Error(writer, err.Error(), 500)
return
}
if item.Action[action] == nil {
http.Error(writer, "no such action", 500)
return
}
argv, err := core.GetArgvForAction(env.db, source, action)
if err != nil {
http.Error(writer, err.Error(), 500)
return
}
itemJson, err := json.Marshal(item)
if err != nil {
http.Error(writer, err.Error(), 500)
return
}
res, err := core.Execute(source, argv, nil, string(itemJson), time.Minute)
if err != nil {
http.Error(writer, err.Error(), 500)
return
}
if len(res) != 1 {
http.Error(writer, "not exactly one item", 500)
return
}
newItem := res[0]
core.BackfillItem(&newItem, &item)
if err = core.UpdateItems(env.db, []core.Item{newItem}); err != nil {
http.Error(writer, err.Error(), 500)
return
}
html.Item(writer, html.ItemData{Item: newItem})
}
func (env *Env) massDeactivate(writer http.ResponseWriter, req *http.Request) {
if err := req.ParseForm(); err != nil {
log.Printf("error parsing form data: %v", err)
http.Error(writer, "", http.StatusBadRequest)
return
}
for _, item := range req.PostForm["items"] {
i := strings.Index(item, "/")
if i == -1 {
log.Printf("error: invalid source/item: %s", item)
http.Error(writer, "", http.StatusBadRequest)
return
}
}
for _, item := range req.PostForm["items"] {
i := strings.Index(item, "/")
source := item[:i]
id := item[i+1:]
active, err := core.DeactivateItem(env.db, source, id)
if err != nil {
log.Printf("error: failed to deactivate %s/%s: %v", source, id, err)
}
if active {
log.Printf("deactivated %s/%s", source, id)
}
}
writer.Header()["HX-Refresh"] = []string{"true"}
http.Error(writer, "ok", http.StatusNoContent)
}

View File

@ -33,6 +33,8 @@ func RunServer(db *core.DB, addr string, port string) {
handleFunc("GET /source/{source}", env.getSource) handleFunc("GET /source/{source}", env.getSource)
handleFunc("GET /item/{source}/{id}", env.getItem) handleFunc("GET /item/{source}/{id}", env.getItem)
handleFunc("DELETE /item/{source}/{id}", env.deleteItem) handleFunc("DELETE /item/{source}/{id}", env.deleteItem)
handleFunc("POST /item/{source}/{id}/action/{action}", env.doAction)
handleFunc("POST /mass-deactivate", env.massDeactivate)
log.Fatal(http.ListenAndServe(bind, nil)) log.Fatal(http.ListenAndServe(bind, nil))
} }