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
@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

View File

@ -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:

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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")
}

View File

@ -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
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
}
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)

View File

@ -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

View File

@ -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;

View File

@ -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}'

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}"
>&#10005;</button>
@ -13,6 +12,16 @@
>&#8631;</button>
{{- if .Link }}<a class="item-link" href="{{ .Link }}" target="_blank">&#8663;</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 -}}

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="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>

View File

@ -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)
}

View File

@ -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))
}