Browse Source

implement RSS-style pull-based subscription feeds

Benton Edmondson 1 year ago
parent
commit
fb27d17159
9 changed files with 245 additions and 42 deletions
  1. 52 12
      main.go
  2. 18 0
      pub/activity.go
  3. 8 0
      pub/actor.go
  4. 0 4
      pub/common.go
  5. 5 0
      pub/failure.go
  6. 6 6
      pub/interfaces.go
  7. 9 1
      pub/post.go
  8. 111 0
      splicer/splicer.go
  9. 36 19
      ui/ui.go

+ 52 - 12
main.go

@@ -1,34 +1,68 @@
 package main
 
 import (
+	"mimicry/config"
+	"mimicry/ui"
 	"os"
-	"golang.org/x/term"
 	"strings"
-	"mimicry/ui"
 	"time"
+
+	"golang.org/x/term"
 )
 
 // TODO: clean up most panics
 
 func main() {
-	if len(os.Args) != 2 { 
-		panic("must provide 2 arguments")
-	}
 	oldTerminal, err := term.MakeRaw(int(os.Stdin.Fd()))
-	if err != nil { panic(err) }
+	if err != nil {
+		panic(err)
+	}
 	defer term.Restore(int(os.Stdin.Fd()), oldTerminal)
 	width, height, err := term.GetSize(int(os.Stdin.Fd()))
-	if err != nil { panic(err) }
-	printRaw("")
+	if err != nil {
+		panic(err)
+	}
+	config, err := config.Parse()
+	if err != nil {
+		panic(err)
+	}
+	state := ui.NewState(config, width, height, printRaw)
 
-	state := ui.Start(os.Args[1], printRaw)
-	state.SetWidthHeight(width, height)
+	if len(os.Args) < 2 {
+		help()
+		return
+	}
+
+	// TODO: resize currently doesn't work until these
+	// network requests complete
+	switch os.Args[1] {
+	case "open":
+		if len(os.Args) == 3 {
+			state.Open(os.Args[2])
+		} else {
+			help()
+			return
+		}
+	case "feed":
+		if len(os.Args) == 2 {
+			state.Feed("default")
+		} else if len(os.Args) == 3 {
+			state.Feed(os.Args[2])
+		} else {
+			help()
+			return
+		}
+	default:
+		panic("expected a command as the first argument")
+	}
 
 	go func() {
 		for {
 			time.Sleep(1 * time.Second)
 			width, height, err := term.GetSize(int(os.Stdin.Fd()))
-			if err != nil { panic(err) }
+			if err != nil {
+				panic(err)
+			}
 			state.SetWidthHeight(width, height)
 		}
 	}()
@@ -50,5 +84,11 @@ func main() {
 func printRaw(output string) {
 	output = strings.ReplaceAll(output, "\n", "\r\n")
 	_, err := os.Stdout.WriteString("\x1b[0;0H\x1b[2J" + output)
-	if err != nil { panic(err) }
+	if err != nil {
+		panic(err)
+	}
+}
+
+func help() {
+	os.Stdout.WriteString("here's the help page\n")
 }

+ 18 - 0
pub/activity.go

@@ -9,6 +9,8 @@ import (
 	"mimicry/ansi"
 	"mimicry/style"
 	"sync"
+	"time"
+	"errors"
 )
 
 type Activity struct {
@@ -16,6 +18,7 @@ type Activity struct {
 	id *url.URL
 
 	actor *Actor; actorErr error
+	created time.Time; createdErr error
 	target Tangible
 }
 
@@ -39,6 +42,8 @@ func NewActivityFromObject(o object.Object, id *url.URL) (*Activity, error) {
 		return nil, fmt.Errorf("%w: %s is not an Activity", ErrWrongType, a.kind)
 	}
 
+	a.created, a.createdErr = o.GetTime("published")
+
 	var wg sync.WaitGroup
 	wg.Add(2)
 	go func () {a.actor, a.actorErr = getActor(o, "actor", a.id); wg.Done()}()
@@ -103,3 +108,16 @@ func (a *Activity) Children() Container {
 func (a *Activity) Parents(quantity uint) ([]Tangible, Tangible) {
 	return a.target.Parents(quantity)
 }
+
+func (a *Activity) Timestamp() time.Time {
+	if errors.Is(a.createdErr, object.ErrKeyNotPresent) {
+		if a.kind == "Create" {
+			return a.target.Timestamp()
+		} else {
+			return time.Time{}
+		}
+	} else if a.createdErr != nil {
+		return time.Time{}
+	}
+	return a.created
+}

+ 8 - 0
pub/actor.go

@@ -195,4 +195,12 @@ func (a Actor) Preview(width int) string {
 	}
 
 	return output
+}
+
+func (a *Actor) Timestamp() time.Time {
+	if a.joinedErr != nil {
+		return time.Time{}
+	} else {
+		return a.joined
+	}
 }

+ 0 - 4
pub/common.go

@@ -13,10 +13,6 @@ var (
 	ErrWrongType = errors.New("item is the wrong type")
 )
 
-const (
-	timeFormat = "3:04 pm on 2 Jan 2006"
-)
-
 type TangibleWithName interface {
 	Tangible
 	Name() string

+ 5 - 0
pub/failure.go

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"mimicry/style"
+	"time"
 )
 
 type Failure struct {
@@ -35,4 +36,8 @@ func (f *Failure) Parents(uint) ([]Tangible, Tangible) {
 
 func (f *Failure) Children() Container {
 	return nil
+}
+
+func (f *Failure) Timestamp() time.Time {
+	return time.Time{}
 }

+ 6 - 6
pub/interfaces.go

@@ -1,8 +1,10 @@
 package pub
 
-type Any interface {
-	Kind() string
-}
+import (
+	"time"
+)
+
+type Any any
 
 type Tangible interface {
 	Kind() string
@@ -11,12 +13,10 @@ type Tangible interface {
 	Preview(width int) string
 	Parents(quantity uint) ([]Tangible, Tangible)
 	Children() Container
+	Timestamp() time.Time
 }
 
 type Container interface {
-	Kind() string
-
 	/* result, index of next item, next collection */
 	Harvest(quantity uint, startingAt uint) ([]Tangible, Container, uint)
-	Size() (uint64, error)
 }

+ 9 - 1
pub/post.go

@@ -169,7 +169,7 @@ func (p *Post) header(width int) string {
 	if p.createdErr != nil && !errors.Is(p.createdErr, object.ErrKeyNotPresent) {
 		output += " at " + style.Problem(p.createdErr)
 	} else {
-		output += " at " + style.Color(p.created.Format(timeFormat))
+		output += " at " + style.Color(time.Since(p.created).Round(time.Minute).String())
 	}
 
 	return ansi.Wrap(output, width)
@@ -266,3 +266,11 @@ func (p *Post) Preview(width int) string {
 
 	return output
 }
+
+func (p *Post) Timestamp() time.Time {
+	if p.createdErr != nil {
+		return time.Time{}
+	} else {
+		return p.created
+	}
+}

+ 111 - 0
splicer/splicer.go

@@ -0,0 +1,111 @@
+package splicer
+
+import (
+	"mimicry/pub"
+	"sync"
+)
+
+type Splicer []struct {
+	basepoint uint
+	page      pub.Container
+	element   pub.Tangible
+}
+
+func (s Splicer) Harvest(quantity uint, startingPoint uint) ([]pub.Tangible, pub.Container, uint) {
+	clone := s.clone()
+
+	for i := 0; i < int(startingPoint); i++ {
+		clone.microharvest()
+	}
+
+	output := make([]pub.Tangible, 0, quantity)
+	for i := 0; i < int(quantity); i++ {
+		harvested := clone.microharvest()
+		if harvested == nil {
+			break
+		}
+		output = append(output, harvested)
+	}
+
+	return output, clone, 0
+}
+
+func (s Splicer) clone() *Splicer {
+	newSplicer := make(Splicer, len(s))
+	copy(newSplicer, s)
+	return &newSplicer
+}
+
+func (s Splicer) microharvest() pub.Tangible {
+	var mostRecent pub.Tangible
+	var mostRecentIndex int
+	for i, candidate := range s {
+		if mostRecent == nil {
+			mostRecent = candidate.element
+			mostRecentIndex = i
+			continue
+		}
+
+		if candidate.element == nil {
+			continue
+		}
+
+		if candidate.element.Timestamp().After(mostRecent.Timestamp()) {
+			mostRecent = candidate.element
+			mostRecentIndex = i
+			continue
+		}
+	}
+
+	if mostRecent == nil {
+		return nil
+	}
+
+	if s[mostRecentIndex].page != nil {
+		var elements []pub.Tangible
+		elements, s[mostRecentIndex].page, s[mostRecentIndex].basepoint = s[mostRecentIndex].page.Harvest(1, s[mostRecentIndex].basepoint)
+		if len(elements) > 1 {
+			panic("harvest returned more that one element when I only asked for one")
+		} else {
+			s[mostRecentIndex].element = elements[0]
+		}
+	} else {
+		s[mostRecentIndex].element = nil
+	}
+
+	return mostRecent
+}
+
+func NewSplicer(inputs []string) *Splicer {
+	s := make(Splicer, len(inputs))
+	var wg sync.WaitGroup
+	for i, input := range inputs {
+		i := i
+		input := input
+		wg.Add(1)
+		go func() {
+			fetched := pub.FetchUserInput(input)
+			var children pub.Container
+			switch narrowed := fetched.(type) {
+			case pub.Tangible:
+				children = narrowed.Children()
+			case *pub.Collection:
+				children = narrowed
+			default:
+				panic("cannot splice non-Tangible, non-Collection")
+			}
+
+			var elements []pub.Tangible
+			elements, s[i].page, s[i].basepoint = children.Harvest(1, 0)
+			if len(elements) > 1 {
+				panic("harvest returned more that one element when I only asked for one")
+			} else {
+				s[i].element = elements[0]
+			}
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+
+	return &s
+}

+ 36 - 19
ui/ui.go

@@ -7,31 +7,35 @@ import (
 	"fmt"
 	"sync"
 	"mimicry/style"
+	"mimicry/config"
+	"mimicry/splicer"
 )
 
 type State struct {
-	m sync.Mutex
+	// TODO: the part stored in the history array is
+	// called page, page will be renamed to children
+	m *sync.Mutex
 
 	feed *feed.Feed
 	index int
-	context int
-
+	
 	frontier pub.Tangible
 	loadingUp bool
-
+	
 	page pub.Container
 	basepoint uint
 	loadingDown bool
-
+	
 	width int
 	height int
-
 	output func(string)
+	
+	config *config.Config
 }
 
 func (s *State) View() string {
 	var top, center, bottom string
-	for i := s.index - s.context; i <= s.index + s.context; i++ {
+	for i := s.index - s.config.Context; i <= s.index + s.config.Context; i++ {
 		if !s.feed.Contains(i) {
 			continue
 		}
@@ -90,7 +94,6 @@ func (s *State) Update(input byte) {
 	case ' ': // select
 		s.m.Lock()
 		s.switchTo(s.feed.Get(s.index))
-		s.output(s.View())
 		s.m.Unlock()
 	}
 	// TODO: the catchall down here will be to look at s.feed.Get(s.index).References()
@@ -110,11 +113,16 @@ func (s *State) switchTo(item pub.Any)  {
 		s.loadSurroundings()
 	case pub.Container:
 		var children []pub.Tangible
-		children, s.page, s.basepoint = narrowed.Harvest(uint(s.context), 0)
+		children, s.page, s.basepoint = narrowed.Harvest(uint(s.config.Context), 0)
 		s.feed = feed.CreateAndAppend(children)
+		s.index = 1
+		s.loadingUp = false
+		s.loadingDown = false
+		s.basepoint = 0
 	default:
 		panic(fmt.Sprintf("unrecognized non-Tangible non-Container: %T", item))
 	}
+	s.output(s.View())
 }
 
 func (s *State) SetWidthHeight(width int, height int) {
@@ -130,10 +138,10 @@ func (s *State) SetWidthHeight(width int, height int) {
 
 func (s *State) loadSurroundings() {
 	var prior State = *s
-	if !s.loadingUp && !s.feed.Contains(s.index - s.context) && s.frontier != nil {
+	if !s.loadingUp && !s.feed.Contains(s.index - s.config.Context) && s.frontier != nil {
 		s.loadingUp = true
 		go func() {
-			parents, newFrontier := prior.frontier.Parents(uint(prior.context))
+			parents, newFrontier := prior.frontier.Parents(uint(prior.config.Context))
 			prior.feed.Prepend(parents)
 			s.m.Lock()
 			if prior.feed == s.feed {
@@ -144,10 +152,10 @@ func (s *State) loadSurroundings() {
 			s.m.Unlock()
 		}()
 	}
-	if !s.loadingDown && !s.feed.Contains(s.index + s.context) && s.page != nil {
+	if !s.loadingDown && !s.feed.Contains(s.index + s.config.Context) && s.page != nil {
 		s.loadingDown = true
 		go func() {
-			children, newPage, newBasepoint := prior.page.Harvest(uint(prior.context), prior.basepoint)
+			children, newPage, newBasepoint := prior.page.Harvest(uint(prior.config.Context), prior.basepoint)
 			prior.feed.Append(children)
 			s.m.Lock()
 			if prior.feed == s.feed {
@@ -161,16 +169,25 @@ func (s *State) loadSurroundings() {
 	}
 }
 
-func Start(input string, output func(string)) *State {
-	item := pub.FetchUserInput(input)
+func (s *State) Open(input string) {
+	s.output(ansi.CenterVertically("", style.Color("  Opening…"), "", uint(s.height)))
+	s.switchTo(pub.FetchUserInput(input))
+}
+
+func (s *State) Feed(input string) {
+	s.output(ansi.CenterVertically("", style.Color("  Loading feed…"), "", uint(s.height)))
+	s.switchTo(splicer.NewSplicer(s.config.Feeds[input]))
+}
+
+func NewState(config *config.Config, width int, height int, output func(string)) *State {
 	s := &State{
 		feed: &feed.Feed{},
 		index: 0,
-		context: 5,
+		config: config,
+		width: width,
+		height: height,
 		output: output,
+		m: &sync.Mutex{},
 	}
-	s.m.Lock()
-	s.switchTo(item)
-	s.m.Unlock()
 	return s
 }