Skip to content

Commit

Permalink
feat: add backlog command to aggregate tasks from multiple pages
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Feb 22, 2025
1 parent f9e643f commit 2273b49
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 7 deletions.
221 changes: 221 additions & 0 deletions cmd/backlog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"github.com/andreoliwa/logseq-go/content"
"github.com/spf13/cobra"
"io"
"net/http"
"os"
"strings"
)

var backlogCmd = &cobra.Command{ //nolint:exhaustruct,gochecknoglobals
Use: "backlog [backlogPage] [queryPages...]",
Short: "Aggregate tasks from multiple pages into a backlog",
Long: `The backlog command aggregates tasks from one or more pages into a single interactive backlog.
The first argument defines the name of the backlog page, while tasks are queried from all provided pages.
This backlog allows users to rearrange tasks with arrow keys and manage task states (start/stop)
directly within the interface.`,
Args: cobra.MinimumNArgs(1),
Run: func(_ *cobra.Command, args []string) {
err := updateBacklog(args)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}

func init() {
rootCmd.AddCommand(backlogCmd)
}

// TODO: refactor this function after all main features are implemented and tested.
func updateBacklog(pages []string) error { //nolint:cyclop,funlen
graph := openGraph("")
if graph == nil {
return ErrFailedOpenGraph
}

pageName := "backlog/" + pages[0]

page, err := graph.OpenPage(pageName)
if err != nil {
return fmt.Errorf("failed to open page: %w", err)
}

if page == nil {
return fmt.Errorf("page %s: %w", pageName, ErrPageNotFound)
}

refsFromPage := NewSet[string]()

for _, block := range page.Blocks() {
block.Children().FindDeep(func(n content.Node) bool {
if ref, ok := n.(*content.BlockRef); ok {
refsFromPage.Add(ref.ID)
}

return false
})
}

query := buildQuery(pages)

jsonStr, err := queryJSON(query)
if err != nil {
return fmt.Errorf("failed to query Logseq API: %w", err)
}

jsonTasks, err := extractTasks(jsonStr)
if err != nil {
return fmt.Errorf("failed to extract tasks: %w", err)
}

refsFromQuery := NewSet[string]()
for _, e := range jsonTasks {
refsFromQuery.Add(e.UUID)
}

newRefs := NewSet[string]()

for _, ref := range refsFromQuery.Values() {
if !refsFromPage.Contains(ref) {
newRefs.Add(ref)
}
}

if newRefs.Size() == 0 {
fmt.Printf("\033[33m%s: no new tasks found\033[0m\n", page.Title())

return nil
}

transaction := graph.NewTransaction()

page, err = transaction.OpenPage(pageName)
if err != nil {
return fmt.Errorf("failed to open page for transaction: %w", err)
}

var first *content.Block
if len(page.Blocks()) == 0 {
first = nil
} else {
first = page.Blocks()[0]
}

for _, ref := range newRefs.Values() {
block := content.NewBlock(content.NewBlockRef(ref))
if first == nil {
page.AddBlock(block)
} else {
page.InsertBlockBefore(block, first)
}
}

divider := content.NewBlock(content.NewParagraph(
content.NewPageLink("quick capture"),
content.NewText(" New tasks above this line"),
))
if first == nil {
page.AddBlock(divider)
} else {
page.InsertBlockBefore(divider, first)
}

err = transaction.Save()
if err != nil {
return fmt.Errorf("failed to save transaction: %w", err)
}

fmt.Printf("\033[92m%s: updated with %d new task(s)\033[0m\n", page.Title(), newRefs.Size())

return nil
}

func buildQuery(tagsOrPages []string) string {
if len(tagsOrPages) == 0 {
return ""
}

var condition string
if len(tagsOrPages) == 1 {
condition = fmt.Sprintf("[[%s]]", tagsOrPages[0])
} else {
withBrackets := make([]string, len(tagsOrPages))
for i, page := range tagsOrPages {
withBrackets[i] = fmt.Sprintf("[[%s]]", page)
}

pages := strings.Join(withBrackets, " ")
condition = "(or " + pages + ")"
}

query := "(and " + condition + " (task TODO DOING WAITING))"

return query
}

// queryJSON sends a query to the Logseq API and returns the result as JSON.
func queryJSON(query string) (string, error) {
apiToken := os.Getenv("LOGSEQ_API_TOKEN")

hostURL := os.Getenv("LOGSEQ_HOST_URL")
if apiToken == "" || hostURL == "" {
return "", ErrMissingConfig
}

client := &http.Client{} //nolint:exhaustruct
payload := strings.NewReader(fmt.Sprintf(`{"method":"logseq.db.q","args":["%s"]}`, query))

ctx := context.Background()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, hostURL+"/api", payload)
if err != nil {
return "", fmt.Errorf("failed to create new request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error performing HTTP request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %s: %w", resp.Status, ErrQueryLogseqAPI)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}

return string(body), nil
}

type taskJSON struct {
UUID string `json:"uuid"`
Marker string `json:"marker"`
Content string `json:"content"`
Page pageJSON `json:"page"`
}

type pageJSON struct {
JournalDay int `json:"journalDay"`
}

func extractTasks(jsonStr string) ([]taskJSON, error) {
var tasks []taskJSON
if err := json.Unmarshal([]byte(jsonStr), &tasks); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}

return tasks, nil
}
8 changes: 8 additions & 0 deletions cmd/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package cmd

import "errors"

var ErrFailedOpenGraph = errors.New("failed to open graph")
var ErrMissingConfig = errors.New("LOGSEQ_API_TOKEN and LOGSEQ_HOST_URL must be set")
var ErrPageNotFound = errors.New("page not found")
var ErrQueryLogseqAPI = errors.New("failed to query Logseq API")
43 changes: 43 additions & 0 deletions cmd/set.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

// Set is a simple implementation of a set using a map.
type Set[T comparable] struct {
data map[T]struct{}
}

// NewSet creates and returns a new set.
func NewSet[T comparable]() *Set[T] {
return &Set[T]{data: make(map[T]struct{})}
}

// Add inserts an element into the set.
func (s *Set[T]) Add(value T) {
s.data[value] = struct{}{}
}

// Remove deletes an element from the set.
func (s *Set[T]) Remove(value T) {
delete(s.data, value)
}

// Contains checks if an element exists in the set.
func (s *Set[T]) Contains(value T) bool {
_, exists := s.data[value]

return exists
}

// Size returns the number of elements in the set.
func (s *Set[T]) Size() int {
return len(s.data)
}

// Values returns all elements in the set as a slice.
func (s *Set[T]) Values() []T {
keys := make([]T, 0, len(s.data))
for key := range s.data {
keys = append(keys, key)
}

return keys
}
7 changes: 0 additions & 7 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -111,16 +110,10 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/cobra v1.9.0 h1:Py5fIuq/lJsRYxcxfOtsJqpmwJWCMOUy2tMJYV8TNHE=
github.com/spf13/cobra v1.9.0/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
Expand Down

0 comments on commit 2273b49

Please sign in to comment.