Compare commits
12 Commits
421271e2c3
...
7bea8c247a
Author | SHA1 | Date | |
---|---|---|---|
7bea8c247a | |||
647584e55b | |||
c4d53eb993 | |||
3519517b96 | |||
7477504508 | |||
3118758f1d | |||
6ef51b7286 | |||
680d8db6bb | |||
7ca6ccfaf3 | |||
f804299180 | |||
d23efdf00b | |||
453bc9d601 |
5
Makefile
5
Makefile
@ -1,7 +1,10 @@
|
||||
.PHONY: help serve
|
||||
.PHONY: help serve test-data
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
test-data: ## Recreate test data in tmp/
|
||||
@test/test_items.sh
|
11
README.md
11
README.md
@ -23,14 +23,14 @@ Parity with existing Python version
|
||||
* [ ] rename
|
||||
* [x] delete
|
||||
* [x] execute
|
||||
* [ ] require items to declare action support
|
||||
* [x] require items to declare action support
|
||||
* [ ] state files
|
||||
* [ ] source environment
|
||||
* [ ] working directory set
|
||||
* [ ] update web UI credentials
|
||||
* [ ] automatic crontab integration
|
||||
* [ ] feed supports item TTS
|
||||
* [ ] data directory from envvars
|
||||
* [x] data directory from envvars
|
||||
* [ ] source-level tt{s,d,l}
|
||||
* [ ] source batching
|
||||
* channels
|
||||
@ -41,12 +41,14 @@ Parity with existing Python version
|
||||
* feeds
|
||||
* [x] show items
|
||||
* [x] deactivate items
|
||||
* [ ] mass deactivate
|
||||
* [x] mass deactivate
|
||||
* [ ] punt
|
||||
* [ ] trigger actions
|
||||
* [x] trigger actions
|
||||
* [x] add ad-hoc items
|
||||
* [ ] show/hide deactivated items
|
||||
* [ ] show/hide tts items
|
||||
* [x] sort by time ?? created
|
||||
* [ ] paging
|
||||
* [ ] NixOS module
|
||||
* [ ] 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.
|
||||
| `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.
|
||||
| `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:
|
||||
|
||||
|
@ -16,6 +16,9 @@ var actionExecuteCmd = &cobra.Command{
|
||||
Short: "Run 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.
|
||||
Use "intake source fetch" to run the fetch action.
|
||||
|
||||
@ -33,6 +36,7 @@ var actionExecuteItem string
|
||||
var actionExecuteFormat string
|
||||
var actionExecuteDryRun bool
|
||||
var actionExecuteDiff bool
|
||||
var actionExecuteForce bool
|
||||
|
||||
func init() {
|
||||
actionCmd.AddCommand(actionExecuteCmd)
|
||||
@ -46,10 +50,12 @@ func init() {
|
||||
actionExecuteCmd.PersistentFlags().StringVarP(&actionExecuteAction, "action", "a", "", "Action to run")
|
||||
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(&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() {
|
||||
@ -67,16 +73,24 @@ func actionExecute() {
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("error: failed to serialize item: %v", err)
|
||||
@ -108,7 +122,7 @@ func actionExecute() {
|
||||
if 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")
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func feed() {
|
||||
items, err = core.GetActiveItemsForSource(db, feedSource)
|
||||
}
|
||||
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 != "" {
|
||||
log.Fatal("error: unimplemented")
|
||||
@ -60,7 +60,7 @@ func feed() {
|
||||
items, err = core.GetAllActiveItems(db)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("error: failed to fetch items")
|
||||
log.Fatalf("error: failed to fetch items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ package cmd
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/Jaculabilis/intake/core"
|
||||
@ -23,46 +23,63 @@ if it doesn't exist, with a random id.`,
|
||||
},
|
||||
}
|
||||
|
||||
var addSource string
|
||||
var addId string
|
||||
var addTitle string
|
||||
var addAuthor string
|
||||
var addBody string
|
||||
var addLink string
|
||||
var addTime int
|
||||
var addItemSource string
|
||||
var addItemId string
|
||||
var addItemTitle string
|
||||
var addItemAuthor string
|
||||
var addItemBody string
|
||||
var addItemLink string
|
||||
var addItemTime int
|
||||
var addItemActions string
|
||||
|
||||
func init() {
|
||||
itemCmd.AddCommand(itemAddCmd)
|
||||
|
||||
itemAddCmd.Flags().StringVarP(&addSource, "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(&addTitle, "title", "t", "", "Item title")
|
||||
itemAddCmd.Flags().StringVarP(&addAuthor, "author", "a", "", "Item author")
|
||||
itemAddCmd.Flags().StringVarP(&addBody, "body", "b", "", "Item body")
|
||||
itemAddCmd.Flags().StringVarP(&addLink, "link", "l", "", "Item link")
|
||||
itemAddCmd.Flags().IntVarP(&addTime, "time", "m", 0, "Item time as a Unix timestamp")
|
||||
itemAddCmd.Flags().StringVarP(&addItemSource, "source", "s", "", "Source in which to create the item (default: default)")
|
||||
itemAddCmd.Flags().StringVarP(&addItemId, "id", "i", "", "Item id (default: random hex)")
|
||||
itemAddCmd.Flags().StringVarP(&addItemTitle, "title", "t", "", "Item title")
|
||||
itemAddCmd.Flags().StringVarP(&addItemAuthor, "author", "a", "", "Item author")
|
||||
itemAddCmd.Flags().StringVarP(&addItemBody, "body", "b", "", "Item body")
|
||||
itemAddCmd.Flags().StringVarP(&addItemLink, "link", "l", "", "Item link")
|
||||
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() {
|
||||
// Default to "default" source
|
||||
if addSource == "" {
|
||||
addSource = "default"
|
||||
if addItemSource == "" {
|
||||
addItemSource = "default"
|
||||
}
|
||||
// Default id to random hex string
|
||||
if addId == "" {
|
||||
if addItemId == "" {
|
||||
bytes := make([]byte, 16)
|
||||
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()
|
||||
|
||||
err := core.AddItem(db, addSource, addId, addTitle, addAuthor, addBody, addLink, addTime)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to add item: %s", err)
|
||||
if err := core.AddItems(db, []core.Item{{
|
||||
Source: addItemSource,
|
||||
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)
|
||||
}
|
||||
|
@ -25,20 +25,20 @@ var sourceTestCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var testEnv []string
|
||||
var testFormat string
|
||||
var sourceTestEnv []string
|
||||
var sourceTestFormat string
|
||||
|
||||
func init() {
|
||||
sourceCmd.AddCommand(sourceTestCmd)
|
||||
|
||||
sourceTestCmd.Flags().StringArrayVarP(&testEnv, "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().StringArrayVarP(&sourceTestEnv, "env", "e", nil, "Environment variables to set, in the form KEY=VAL")
|
||||
sourceTestCmd.Flags().StringVarP(&sourceTestFormat, "format", "f", "headlines", "Feed format for returned items.")
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -147,4 +147,36 @@ func TestExecute(t *testing.T) {
|
||||
res, err = execute([]string{"jq", "-cn", `["a", "a"] | .[] | {id: .}`})
|
||||
assertNil(err)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
@ -11,21 +11,21 @@ func TestDeleteSourceCascade(t *testing.T) {
|
||||
db := EphemeralDb(t)
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("failed to add source2: %v", err)
|
||||
}
|
||||
if err := AddItem(db, "source1", "item1", "", "", "", "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddItem(db, "source2", "item2", "", "", "", "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
if err := AddItems(db, []Item{
|
||||
{"source1", "item1", 0, true, "", "", "", "", 0, nil},
|
||||
{"source2", "item2", 0, true, "", "", "", "", 0, nil},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to add items: %v", err)
|
||||
}
|
||||
|
||||
items, err := GetAllActiveItems(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("failed to get active items: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatal("Expected 2 items")
|
||||
@ -39,10 +39,10 @@ func TestDeleteSourceCascade(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal("Unexpected success adding item for nonexistent source")
|
||||
}
|
||||
|
35
core/item.go
35
core/item.go
@ -1,21 +1,33 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 {
|
||||
Source string `json:"source"`
|
||||
Id string `json:"id"`
|
||||
Created int `json:"created"`
|
||||
Active bool `json:"active"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Link string `json:"link"`
|
||||
Time int `json:"time"`
|
||||
Source string `json:"source"`
|
||||
Id string `json:"id"`
|
||||
Created int `json:"created"`
|
||||
Active bool `json:"active"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Link string `json:"link"`
|
||||
Time int `json:"time"`
|
||||
Action Actions `json:"action"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
title := item.Title
|
||||
if title == "" {
|
||||
|
51
core/item_test.go
Normal file
51
core/item_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -48,37 +48,23 @@ func DeleteSource(db *DB, name string) error {
|
||||
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 {
|
||||
return db.Transact(func(tx *sql.Tx) error {
|
||||
stmt, err := tx.Prepare(`
|
||||
insert into items (source, id, active, title, author, body, link, time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
insert into items (source, id, active, title, author, body, link, time, action)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?))
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to prepare insert: %v", err)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
@ -118,7 +104,8 @@ func UpdateItems(db *DB, items []Item) error {
|
||||
author = ?,
|
||||
body = ?,
|
||||
link = ?,
|
||||
time = ?
|
||||
time = ?,
|
||||
action = jsonb(?)
|
||||
where source = ?
|
||||
and id = ?
|
||||
`)
|
||||
@ -126,7 +113,11 @@ func UpdateItems(db *DB, items []Item) error {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -179,7 +170,7 @@ func getItems(db *DB, query string, args ...any) ([]Item, error) {
|
||||
var items []Item
|
||||
for rows.Next() {
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
where source = ?
|
||||
and id = ?
|
||||
order by case when time = 0 then created else time end, id
|
||||
`, source, id)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
@ -207,42 +199,48 @@ func GetItem(db *DB, source string, id string) (Item, error) {
|
||||
func GetAllActiveItems(db *DB) ([]Item, error) {
|
||||
return getItems(db, `
|
||||
select
|
||||
source, id, created, active, title, author, body, link, time
|
||||
source, id, created, active, title, author, body, link, time, json(action)
|
||||
from items
|
||||
where active <> 0
|
||||
order by case when time = 0 then created else time end, id
|
||||
`)
|
||||
}
|
||||
|
||||
func GetAllItems(db *DB) ([]Item, error) {
|
||||
return getItems(db, `
|
||||
select
|
||||
source, id, created, active, title, author, body, link, time
|
||||
source, id, created, active, title, author, body, link, time, json(action)
|
||||
from items
|
||||
order by case when time = 0 then created else time end, id
|
||||
`)
|
||||
}
|
||||
|
||||
func GetActiveItemsForSource(db *DB, source string) ([]Item, error) {
|
||||
return getItems(db, `
|
||||
select
|
||||
source, id, created, active, title, author, body, link, time
|
||||
source, id, created, active, title, author, body, link, time, json(action)
|
||||
from items
|
||||
where
|
||||
source = ?
|
||||
and active <> 0
|
||||
order by case when time = 0 then created else time end, id
|
||||
`, source)
|
||||
}
|
||||
|
||||
func GetAllItemsForSource(db *DB, source string) ([]Item, error) {
|
||||
return getItems(db, `
|
||||
select
|
||||
source, id, created, active, title, author, body, link, time
|
||||
source, id, created, active, title, author, body, link, time, json(action)
|
||||
from items
|
||||
where
|
||||
source = ?
|
||||
order by case when time = 0 then created else time end, id
|
||||
`, source)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Get the existing items
|
||||
existingItems, err := GetAllItemsForSource(db, source)
|
||||
|
@ -57,24 +57,25 @@ func AssertItemIs(t *testing.T, item Item, expected string) {
|
||||
func TestAddItem(t *testing.T) {
|
||||
db := EphemeralDb(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddItem(db, "test", "two", "title", "author", "body", "link", 123456); err != nil {
|
||||
t.Fatal(err)
|
||||
if err := AddItems(db, []Item{
|
||||
{"test", "one", 0, true, "", "", "", "", 0, nil},
|
||||
{"test", "two", 0, true, "title", "author", "body", "link", 123456, nil},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to add items: %v", err)
|
||||
}
|
||||
items, err := GetActiveItemsForSource(db, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("failed to get active items: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatal("should get two items")
|
||||
}
|
||||
AssertItemIs(t, items[0], "test/one/true/////0")
|
||||
AssertItemIs(t, items[1], "test/two/true/title/author/body/link/123456")
|
||||
// order is by (time ?? created) so this ordering is correct as long as you don't run it in early 1970
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
@ -205,7 +206,7 @@ func TestOnCreateAction(t *testing.T) {
|
||||
}
|
||||
updated := getItem("one")
|
||||
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])
|
||||
}
|
||||
|
||||
@ -219,8 +220,9 @@ func TestOnCreateAction(t *testing.T) {
|
||||
if add != 1 || err != nil {
|
||||
t.Fatal("failed update with alter oncreate")
|
||||
}
|
||||
if getItem("two").Title != "Goodbye, World" {
|
||||
t.Fatal("title not updated")
|
||||
two := getItem("two")
|
||||
if two.Title != "Goodbye, World" {
|
||||
t.Fatalf("title not updated, is: %s", two.Title)
|
||||
}
|
||||
|
||||
// on_create can add a field
|
||||
|
@ -19,6 +19,7 @@ create table items(
|
||||
body text,
|
||||
link text,
|
||||
time int,
|
||||
action blob,
|
||||
primary key (source, id),
|
||||
foreign key (source) references sources (name) on delete cascade
|
||||
) strict;
|
||||
|
@ -5,6 +5,7 @@ go build -o tmp/intake
|
||||
rm tmp/intake.db* || true
|
||||
export INTAKE_DATA_DIR="tmp"
|
||||
tmp/intake migrate
|
||||
|
||||
tmp/intake source add -s feedtest
|
||||
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"
|
||||
@ -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, 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 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}'
|
||||
|
@ -14,7 +14,11 @@
|
||||
{{ end }}
|
||||
|
||||
<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>
|
||||
|
||||
{{ else }}
|
||||
|
@ -2,8 +2,10 @@ package html
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Jaculabilis/intake/core"
|
||||
@ -18,9 +20,25 @@ func tsToDate(t int) string {
|
||||
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{
|
||||
"raw": rawHtml,
|
||||
"tsToDate": tsToDate,
|
||||
"raw": rawHtml,
|
||||
"tsToDate": tsToDate,
|
||||
"massDeacVars": massDeactivateVals,
|
||||
}
|
||||
|
||||
//go:embed intake.css
|
||||
|
@ -3,7 +3,6 @@
|
||||
class="item-button"
|
||||
title="Deactivate {{ .Source }}/{{ .Id }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="article"
|
||||
hx-delete="/item/{{ .Source }}/{{ .Id }}"
|
||||
>✕</button>
|
||||
@ -13,6 +12,16 @@
|
||||
>↷</button>
|
||||
{{- if .Link }}<a class="item-link" href="{{ .Link }}" target="_blank">⇗</a>
|
||||
{{ 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 }}
|
||||
|
||||
{{ define "item-title" -}}
|
||||
@ -34,9 +43,7 @@
|
||||
{{ template "item-buttons" . }}
|
||||
{{ template "item-title" . }}
|
||||
</summary>
|
||||
{{ if .Body }}
|
||||
<p>{{ raw .Body }}</p>
|
||||
{{ end }}
|
||||
</details>
|
||||
{{ template "item-buttons" . }}
|
||||
{{- else -}}
|
||||
|
@ -6,7 +6,7 @@
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwgAADsIBFShKgAAAABh0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMS41ZEdYUgAAAGFJREFUOE+lkFEKwDAIxXrzXXB3ckMm9EnAV/YRCxFCcUXEL3Jc77NDjpDA/VGL3RFWYEICfeGC8oQc9IPuCAnQDcoRVmBCAn3hgvKEHPSD7ggJ0A3KEVZgQgJ94YLSJ9YDUzNGDXGZ/JEAAAAASUVORK5CYII=">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<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>
|
||||
<body>
|
||||
<main>
|
||||
|
82
web/item.go
82
web/item.go
@ -1,7 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Jaculabilis/intake/core"
|
||||
"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})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -33,6 +33,8 @@ func RunServer(db *core.DB, addr string, port string) {
|
||||
handleFunc("GET /source/{source}", env.getSource)
|
||||
handleFunc("GET /item/{source}/{id}", env.getItem)
|
||||
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))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user