Selaa lähdekoodia

interactivity implemented (can scroll through feed)

Benton Edmondson 1 vuosi sitten
vanhempi
commit
392d84532f
18 muutettua tiedostoa jossa 1098 lisäystä ja 839 poistoa
  1. 6 0
      go.mod
  2. 6 0
      go.sum
  3. 35 34
      main.go
  4. 7 27
      object/object.go
  5. 97 0
      pub/activity.go
  6. 133 44
      pub/actor.go
  7. 101 117
      pub/collection.go
  8. 213 0
      pub/common.go
  9. 0 200
      pub/construct.go
  10. 0 18
      pub/construct_test.go
  11. 38 0
      pub/failure.go
  12. 22 0
      pub/interfaces.go
  13. 0 8
      pub/item.go
  14. 99 56
      pub/link.go
  15. 0 187
      pub/object.go
  16. 198 148
      pub/post.go
  17. 28 0
      pub/user-input.go
  18. 115 0
      ui/ui.go

+ 6 - 0
go.mod

@@ -5,4 +5,10 @@ go 1.20
 require (
 	github.com/yuin/goldmark v1.5.4
 	golang.org/x/net v0.8.0
+	golang.org/x/term v0.6.0
+)
+
+require (
+	golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect
+	golang.org/x/sys v0.6.0 // indirect
 )

+ 6 - 0
go.sum

@@ -1,4 +1,10 @@
 github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
 github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4=
+golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
 golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=

+ 35 - 34
main.go

@@ -2,47 +2,48 @@ package main
 
 import (
 	"fmt"
-	"mimicry/pub"
 	"os"
-	"encoding/json"
+	"golang.org/x/term"
+	"strings"
+	"mimicry/ui"
+	"log"
 )
 
-// TODO: when returning errors, use zero value for return
-// also change all error messages to using sprintf-style
-// formatting, all lowercase, and no punctuation
-
-// TODO: get rid of Raw, just use jtp.Get and then stringify the result
+// TODO: clean up most panics
 
 func main() {
-
-	link := os.Args[len(os.Args)-1]
-	command := os.Args[1]
-
-	item, err := pub.FetchUserInput(link)
-	if err != nil {
-		panic(err)
+	if len(os.Args) != 2 { 
+		panic("must provide 2 arguments")
 	}
-
-	if command == "raw" {
-		enc := json.NewEncoder(os.Stdout)
-		if err := enc.Encode(item); err != nil {
-			panic(err)
+	oldTerminal, err := term.MakeRaw(int(os.Stdin.Fd()))
+	if err != nil { panic(err) }
+	defer term.Restore(int(os.Stdin.Fd()), oldTerminal)
+	width, heightInt, err := term.GetSize(int(os.Stdin.Fd()))
+	if err != nil { panic(err) }
+	height := uint(heightInt)
+	log.Printf("h, w: %v, %v", height, width)
+	printRaw("")
+
+	state := ui.Start(os.Args[1])
+	printRaw(state.View(width, height))
+
+	buffer := make([]byte, 1)
+	for {
+		os.Stdin.Read(buffer)
+		input := buffer[0]
+
+		if input == 3 /*(ctrl+c)*/ || input == 'q' {
+			printRaw("")
+			return
 		}
-		return
-	}
 
-	// if narrowed, ok := content.(pub.Post); ok {
-	// 	if str, err := narrowed.Preview(); err != nil {
-	// 		panic(err)
-	// 	} else {
-	// 		fmt.Print(str)
-	// 	}
-	// 	return
-	// }
-
-	if str, err := item.String(90); err != nil {
-		panic(err)
-	} else {
-		fmt.Print(str)
+		state.Update(input)
+		printRaw(state.View(width, height))
 	}
 }
+
+func printRaw(output string) {
+	output = strings.ReplaceAll(output, "\n", "\r\n")
+	_, err := fmt.Print("\x1b[2J\x1b[0;0H" + output)
+	if err != nil { panic(err) }
+}

+ 7 - 27
object/object.go

@@ -5,7 +5,6 @@ import (
 	"net/url"
 	"time"
 	"mimicry/mime"
-	"mimicry/render"
 	"fmt"
 )
 
@@ -19,7 +18,7 @@ var (
 /* Go doesn't allow generic methods */
 func getPrimitive[T any](o Object, key string) (T, error) {
 	var zero T
-	if value, ok := o[key]; !ok {
+	if value, ok := o[key]; !ok || value == nil {
 		return zero, fmt.Errorf("failed to extract \"%s\": %w", key, ErrKeyNotPresent)
 	} else if narrowed, ok := value.(T); !ok {
 		return zero, fmt.Errorf("failed to extract \"%s\": %w: is %T", key, ErrKeyWrongType, value)
@@ -28,10 +27,15 @@ func getPrimitive[T any](o Object, key string) (T, error) {
 	}
 }
 
+func (o Object) GetAny(key string) (any, error) {
+	return getPrimitive[any](o, key)
+}
+
 func (o Object) GetString(key string) (string, error) {
 	return getPrimitive[string](o, key)
 }
 
+// TODO: should probably error for non-uints
 func (o Object) GetNumber(key string) (uint64, error) {
 	if number, err := getPrimitive[float64](o, key); err != nil {
 		return 0, err
@@ -45,7 +49,7 @@ func (o Object) GetObject(key string) (Object, error) {
 }
 
 func (o Object) GetList(key string) ([]any, error) {
-	if value, err := getPrimitive[any](o, key); err != nil {
+	if value, err := o.GetAny(key); err != nil {
 		return nil, err
 	} else if asList, isList := value.([]any); isList {
 		return asList, nil
@@ -124,27 +128,3 @@ func (o Object) GetNatural(key string, language string) (string, error) {
 
 	return "", fmt.Errorf("failed to extract natural \"%s\": %w", key, ErrKeyNotPresent)
 }
-
-func (o Object) Has(key string) bool {
-	_, present := o[key]
-	return present
-}
-func (o Object) HasNatural(key string) bool {
-	return o.Has(key) || o.Has(key+"Map")
-}
-
-func (o Object) Render(contentKey string, langKey string, mediaTypeKey string, width int) (string, error) {
-	body, err := o.GetNatural(contentKey, langKey)
-	if err != nil {
-		return "", err
-	}
-	mediaType, err := o.GetMediaType(mediaTypeKey)
-	if err != nil {
-		if errors.Is(err, ErrKeyNotPresent) {
-			mediaType = mime.Default()
-		} else {
-			return "", nil
-		}
-	}
-	return render.Render(body, mediaType.Essence, width)
-}

+ 97 - 0
pub/activity.go

@@ -0,0 +1,97 @@
+package pub
+
+import (
+	"net/url"
+	"mimicry/object"
+	"mimicry/client"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/ansi"
+	"mimicry/style"
+)
+
+type Activity struct {
+	kind string
+	id *url.URL
+
+	actor *Actor; actorErr error
+	target Tangible
+}
+
+func NewActivity(input any, source *url.URL) (*Activity, error) {
+	a := &Activity{}
+	var err error; var o object.Object
+	o, a.id, err = client.FetchUnknown(input, source)
+	if err != nil { return nil, err }
+	if a.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Create", "Announce", "Dislike", "Like",
+	}, a.kind) {
+		return nil, fmt.Errorf("%w: %s is not an Activity", ErrWrongType, a.kind)
+	}
+
+	// TODO: parallelize
+	a.actor, a.actorErr = getActor(o, "actor", a.id)
+	a.target = getPostOrActor(o, "object", a.id)
+
+	return a, nil
+}
+
+func (a *Activity) Kind() string {
+	return a.kind
+}
+
+func (a *Activity) header(width int) string {
+	if a.kind == "Create" {
+		return ""
+	}
+
+	var output string
+	if a.actorErr != nil {
+		output += style.Problem(a.actorErr)
+	} else {
+		output += a.actor.Name()
+	}
+
+	output += " "
+
+	switch a.kind {
+	case "Announce":
+		output += style.Color("retweeted")
+	case "Like":
+		output += style.Color("upvoted")
+	case "Dislike":
+		output += style.Color("downvoted")
+	default:
+		panic("encountered unrecognized Actor type: " + a.kind)
+	}
+
+	output += ":\n"
+
+	return ansi.Wrap(output, width)
+}
+
+func (a *Activity) String(width int) string {
+	output := a.header(width)
+
+	output += a.target.String(width)
+	return output
+}
+
+func (a *Activity) Preview(width int) string {
+	output := a.header(width)
+
+	output += a.target.Preview(width)
+	return output
+}
+
+func (a *Activity) Children(quantity uint) ([]Tangible, Container, uint) {
+	return a.target.Children(quantity)
+}
+
+func (a *Activity) Parents(quantity uint) []Tangible {
+	return a.target.Parents(quantity)
+}

+ 133 - 44
pub/actor.go

@@ -3,74 +3,163 @@ package pub
 import (
 	"net/url"
 	"mimicry/style"
+	"errors"
+	"mimicry/object"
+	"time"
+	"mimicry/client"
+	"golang.org/x/exp/slices"
+	"fmt"
+	"strings"
+	"mimicry/ansi"
+	"mimicry/mime"
+	"mimicry/render"
 )
 
 type Actor struct {
-	Object
+	kind string
+	name string; nameErr error
+	handle string; handleErr error
+
+	id *url.URL
+
+	bio string; bioErr error
+	mediaType *mime.MediaType; mediaTypeErr error
+
+	joined time.Time; joinedErr error
+
+	pfp *Link; pfpErr error
+	banner *Link; bannerErr error
+
+	posts *Collection; postsErr error
 }
 
-func (a Actor) Kind() string {
-	kind, err := a.GetString("type")
-	if err != nil {
-		panic(err)
+func NewActor(input any, source *url.URL) (*Actor, error) {
+	a := &Actor{}
+	var o object.Object; var err error
+	o, a.id, err = client.FetchUnknown(input, source)
+	if err != nil { return nil, err }
+	if a.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Application", "Group", "Organization", "Person", "Service",
+	}, a.kind) {
+		return nil, fmt.Errorf("%w: %s is not an Actor", ErrWrongType, a.kind)
 	}
-	return kind
+
+	a.name, a.nameErr = o.GetNatural("name", "en")
+	a.handle, a.handleErr = o.GetString("preferredUsername")
+	a.bio, a.bioErr = o.GetNatural("summary", "en")
+	a.mediaType, a.mediaTypeErr = o.GetMediaType("mediaType")
+	a.joined, a.joinedErr = o.GetTime("published")
+
+	// TODO: parallelize
+	a.pfp, a.pfpErr = getBestLink(o, "icon", "image")
+	a.banner, a.bannerErr = getBestLink(o, "image", "image")
+	a.posts, a.postsErr = getCollection(o, "outbox", a.id)
+	return a, nil
+}
+
+func (a *Actor) Kind() string {
+	return a.kind
+}
+
+func (a *Actor) Parents(quantity uint) []Tangible {
+	return []Tangible{}
 }
 
-func (a Actor) Name() (string, error) {
-	if a.Has("preferredUsername") && !a.HasNatural("name") {
-		name, err := a.GetString("preferredUsername")
-		if err != nil { return "", err }
-		return "@" + name, nil
+func (a *Actor) Children(quantity uint) ([]Tangible, Container, uint) {
+	if errors.Is(a.postsErr, object.ErrKeyNotPresent) {
+		return []Tangible{}, nil, 0
 	}
-	return a.GetNatural("name", "en")
+	if a.postsErr != nil {
+		return []Tangible{
+			NewFailure(a.postsErr),
+		}, nil, 0
+	}
+	return a.posts.Harvest(quantity, 0)
 }
 
-func (a Actor) InlineName() (string, error) {
-	name, err := a.Name()
-	if err != nil {
-		return "", err
+// TODO: here is where I'd put forgery errors in
+func (a *Actor) Name() string {
+	var output string
+	if a.nameErr == nil {
+		output = a.name
+	} else if !errors.Is(a.nameErr, object.ErrKeyNotPresent) {
+		output = style.Problem(a.nameErr)
 	}
-	kind := a.Kind()
-	var suffix string
-	id, err := a.Identifier()
-	if err == nil {
-		if kind == "person" {
-			suffix = "(" + id.Hostname() + ")"
+
+	if a.id != nil && !errors.Is(a.handleErr, object.ErrKeyNotPresent) {
+		if output != "" { output += " " }
+		if a.handleErr != nil {
+			output += style.Problem(a.handleErr)
 		} else {
-			suffix = "(" + id.Hostname() + ", " + kind + ")"
+			output += style.Italic("@" + a.handle + "@" + a.id.Host)
 		}
 	}
-	return name + " " + suffix, nil
-}
 
-func (a Actor) Category() string {
-	return "actor"
-}
+	if a.kind != "Person" {
+		if output != "" { output += " " }
+		output += "(" + strings.ToLower(a.kind) + ")"
+	} else if output == "" {
+		output = strings.ToLower(a.kind)
+	}
 
-func (a Actor) Identifier() (*url.URL, error) {
-	return a.GetURL("id")
+	return style.Color(output)
 }
 
-func (a Actor) Bio() (string, error) {
-	return a.Render("summary", "en", "mediaType", 80)
+func (a *Actor) header(width int) string {
+	output := a.Name()
+
+	if a.joinedErr != nil && !errors.Is(a.joinedErr, object.ErrKeyNotPresent) {
+		output += "\njoined " + style.Problem(a.joinedErr)
+	} else {
+		output += "\njoined " + style.Color(a.joined.Format(timeFormat))
+	}
+
+	return ansi.Wrap(output, width)
 }
 
-func (a Actor) String(width int) (string, error) {
-	output := ""
+func (a *Actor) center(width int) (string, bool) {
+	if errors.Is(a.bioErr, object.ErrKeyNotPresent) {
+		return "", false
+	}
+	if a.bioErr != nil {
+		return ansi.Wrap(style.Problem(a.bioErr), width), true
+	}
 
-	name, err := a.InlineName()
-	if err == nil {
-		output += style.Bold(name)
+	mediaType := a.mediaType
+	if errors.Is(a.mediaTypeErr, object.ErrKeyNotPresent) {
+		mediaType = mime.Default()
+	} else if a.mediaTypeErr != nil {
+		return ansi.Wrap(style.Problem(a.mediaTypeErr), width), true
 	}
-	bio, err := a.Bio()
-	if err == nil {
-		output += "\n"
-		output += bio
+
+	rendered, err := render.Render(a.bio, mediaType.Essence, width)
+	if err != nil {
+		return style.Problem(err), true
 	}
-	return output, nil
+	return rendered, true
 }
 
-func (a Actor) Preview() (string, error) {
-	return "todo", nil
+func (a *Actor) String(width int) string {
+	output := a.header(width)
+
+	if body, present := a.center(width - 4); present {
+		output += "\n\n" + ansi.Indent(body, "  ", true)
+	}
+
+	return output
+}
+
+func (a Actor) Preview(width int) string {
+	output := a.header(width)
+
+	// TODO this needs to be truncated
+	if body, present := a.center(width); present {
+		output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
+	}
+
+	return output
 }

+ 101 - 117
pub/collection.go

@@ -1,150 +1,134 @@
 package pub
 
 import (
-	"strings"
 	"net/url"
-	"strconv"
+	"mimicry/object"
+	"errors"
+	"mimicry/client"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"log"
 )
 
+/*
+	Methods are:
+	Category
+	Kind
+	Identifier
+	Next
+	Size
+	Items (returns list)
+	String // maybe just show this page, and Next can be a button
+		// the infiniscroll will be provided by the View package
+*/
+
+// Should probably take in a constructor, actor gives NewActivity
+// and Post gives NewPost, but not exactly, they can wrap them
+// in a function which also checks whether the element is
+// valid in the given context
+
 type Collection struct {
-	Object
+	kind string
+	id *url.URL
 
-	// index *within the current page*
-	index int
-}
+	elements []any; elementsErr error
+	next any; nextErr error
 
-func (c Collection) Kind() string {
-	kind, err := c.GetString("type")
-	if err != nil { panic(err) }
-	return kind
+	size uint64; sizeErr error
 }
 
-func (c Collection) Category() string {
-	return "collection"
-}
+func NewCollection(input any, source *url.URL) (*Collection, error) {
+	c := &Collection{}
+	var o object.Object; var err error
+	o, c.id, err = client.FetchUnknown(input, source)
+	if err != nil { return nil, err }
+	if c.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
 
-func (c Collection) Identifier() (*url.URL, error) {
-	return c.GetURL("id")
-}
+	if !slices.Contains([]string{
+		"Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage",
+	}, c.kind) {
+		return nil, fmt.Errorf("%w: %s is not a Collection", ErrWrongType, c.kind)
+	}
 
-func (c Collection) String(width int) (string, error) {
-	elements := []string{}
+	if c.kind == "Collection" || c.kind == "CollectionPage" {
+		c.elements, c.elementsErr = o.GetList("items")
+	} else {
+		c.elements, c.elementsErr = o.GetList("orderedItems")
+	}
 
-	const elementsToShow = 3
-	for len(elements) < elementsToShow {
+	if c.kind == "Collection" || c.kind == "OrderedCollection" {
+		c.next, c.nextErr = o.GetAny("first")
+	} else {
+		c.next, c.nextErr = o.GetAny("next")
+	}
 
-		current, err := c.Current()
-		if current == nil && err == nil {
-			break
-		}
+	c.size, c.sizeErr = o.GetNumber("totalItems")
 
-		if err != nil {
-			// TODO: add a beautiful message here saying
-			// failed to load comment: <error>
-			c.Next()
-			continue
-		}
+	return c, nil
+}
 
-		output, err := current.Preview()
-		if err != nil {
-			return "", err
-		}
+func (c *Collection) Kind() string {
+	return c.kind
+}
 
-		elements = append(elements, output)
-		c.Next()
-	}
-	
-	return strings.Join(elements, "\n"), nil
+func (c *Collection) Size() (uint64, error) {
+	return c.size, c.sizeErr
 }
 
-func (c Collection) Size() (string, error) {
-	value, err := c.GetNumber("totalItems")
-	if err != nil {
-		return "", err
+func (c *Collection) Harvest(amount uint, startingPoint uint) ([]Tangible, Container, uint) {
+	// To work through this problem you need to go through this step by step and
+	// make sure the logic is good. Then you should probably start writing some tests
+	
+	log.Printf("amount: %d starting: %d", amount, startingPoint)
+	if c.elementsErr != nil && !errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
+		return []Tangible{NewFailure(c.elementsErr)}, nil, 0
 	}
-	return strconv.FormatUint(value, 10), nil
-}
 
-func (c Collection) items() []any {
-	if c.Has("items") {
-		if list, err := c.GetList("items"); err == nil {
-			return list
-		} else {
-			return []any{}
-		}
+	var length uint
+	if errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
+		length = 0
+	} else {
+		length = uint(len(c.elements))
 	}
-	if c.Has("orderedItems") {
-		if list, err := c.GetList("orderedItems"); err == nil {
-			return list
-		} else {
-			return []any{}
-		}
+	log.Printf("length: %d", length)
+
+	// TODO: change to bool nextWillBeFetched in which case amount from this page is all
+	// and later on the variable is clear
+
+	var amountFromThisPage uint
+	if startingPoint >= length {
+		amountFromThisPage = 0
+	} else if length > amount + startingPoint {
+		amountFromThisPage = amount
+	} else {
+		amountFromThisPage = length - startingPoint
 	}
-	return []any{}
-}
 
-func (c *Collection) Next() (Item, error) {
-	c.index += 1
-	return c.Current()
-}
+	log.Printf("amount from this page: %d", amountFromThisPage)
+	fromThisPage := make([]Tangible, amountFromThisPage)
+	var fromLaterPages []Tangible
+	var nextCollection Container
+	var nextStartingPoint uint
 
-func (c *Collection) Previous() (Item, error) {
-	c.index -= 1
-	return c.Current()
-}
+	// TODO: parallelize this
 
-/* This return type is a Option<Result<Item>>
-   where nil, nil represents None (end of collection)
-   nil, err represents Some(Err()) (current item failed construction)
-   x, nil represent Some(x) (current item)
-   and x, err is invalid */
-func (c *Collection) Current() (Item, error) {
-	items := c.items()
-	if len(items) == 0 {
-		kind := c.Kind()
-		/* If it is a collection, get the first page */
-		if kind == "Collection" || kind == "OrderedCollection" {
-			first, firstErr := c.GetCollection("first")
-			if firstErr != nil {
-				return nil, nil
-			}
-			c.Object = first.Object
-			c.index = 0
-			return c.Current()
-		}
+	for i := uint(0); i < amountFromThisPage; i++ {
+		fromThisPage[i] = NewTangible(c.elements[i+startingPoint], c.id)
 	}
 
-	/* This means we are beyond the end of this page */
-	if c.index >= len(items) {
-		next, err := c.GetCollection("next")
-		if err != nil {
-			return nil, nil
-		}
-		c.Object = next.Object
-		c.index = 0
-		/* Call recursively because the next page may be empty */
-		return c.Current()
-	} else if c.index < 0 {
-		prev, err := c.GetCollection("prev")
-		if err != nil {
-			return nil, nil
+	if errors.Is(c.nextErr, object.ErrKeyNotPresent) || length > amount + startingPoint {
+		fromLaterPages, nextCollection, nextStartingPoint = []Tangible{}, c, amount + startingPoint
+	} else {
+		if c.nextErr != nil {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(c.nextErr)}, c, amount + startingPoint
+		} else if next, err := NewCollection(c.next, c.id); err != nil {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(err)}, c, amount + startingPoint
+		} else {
+			fromLaterPages, nextCollection, nextStartingPoint = next.Harvest(amount - amountFromThisPage, 0)
 		}
-		c.Object = prev.Object
-		items := c.items()
-		/* If this new page is empty, this will be -1, and the
-		   call to Current will flip back yet another page, as
-		   intended */
-		c.index = len(items)-1
-		return c.Current()
 	}
 
-	/* At this point we know index is within items */ 
-
-	id, _ := c.Identifier()
-
-	return FetchUnknown(items[c.index], id)
-}
-
-func (c Collection) Preview() (string, error) {
-	return "I will get rid of this function", nil
+	return append(fromThisPage, fromLaterPages...), nextCollection, nextStartingPoint
 }

+ 213 - 0
pub/common.go

@@ -0,0 +1,213 @@
+package pub
+
+import (
+	"mimicry/object"
+	"fmt"
+	"errors"
+	"net/url"
+)
+
+var (
+	ErrWrongType = errors.New("item is the wrong type")
+)
+
+const (
+	timeFormat = "3:04 pm on 2 Jan 2006"
+)
+
+/*
+	This implements functions common to the different types.
+	- getActors
+	- getCollection
+	- getActor
+	- getPostOrActor
+	- NewTangible
+
+	// these will return an error on any problem
+	- getBestLink, link impl will need the link, Rating(), mediatype, and be willing to take in Posts or Links
+	- getFirstLinkShorthand
+	- getBestLinkShorthand
+
+	// used exclusively for attachments, honestly I
+	// think it should probably return markup.
+	// probably should actually be a function within 
+	// Post
+	- getLinks
+*/
+
+type TangibleWithName interface {
+	Tangible
+	Name() string
+}
+func getActors(o object.Object, key string, source *url.URL) []TangibleWithName {
+	list, err := o.GetList(key)
+	if errors.Is(err, object.ErrKeyNotPresent) {
+		return []TangibleWithName{}
+	} else if err != nil {
+		return []TangibleWithName{NewFailure(err)}
+	}
+
+	// TODO: parallelize will probably require making fixed size
+	// full width, swapping publics for nils, then later filtering
+	// out the nils to reach a dynamic width
+	output := []TangibleWithName{}
+	for _, element := range list {
+		if narrowed, ok := element.(string); ok {
+			if narrowed == "https://www.w3.org/ns/activitystreams#Public" ||
+			narrowed == "as:Public" ||
+			narrowed == "Public" {
+			continue
+		}
+		}
+
+		fetched, err := NewActor(element, source)
+		if err != nil {
+			output = append(output, NewFailure(err))
+		} else {
+			output = append(output, fetched)
+		}
+	}
+	return output
+}
+
+func getPostOrActor(o object.Object, key string, source *url.URL) Tangible {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return NewFailure(err)
+	}
+
+	// TODO: add special case for lemmy where a json object with
+	// type Create is automatically unwrapped right here
+
+	var fetched Tangible
+	fetched, err = NewActor(reference, source)
+	if errors.Is(err, ErrWrongType) {
+		fetched, err = NewPost(reference, source)
+	}
+	if err != nil {
+		return NewFailure(err)
+	}
+	return fetched
+}
+
+func getCollection(o object.Object, key string, source *url.URL) (*Collection, error) {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return nil, err
+	}
+
+	fetched, err := NewCollection(reference, source)
+	if err != nil {
+		return nil, err
+	}
+	return fetched, nil
+}
+
+func getActor(o object.Object, key string, source *url.URL) (*Actor, error) {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return nil, err
+	}
+
+	fetched, err := NewActor(reference, source)
+	if err != nil {
+		return nil, err
+	}
+	return fetched, nil
+}
+
+func NewTangible(input any, source *url.URL) Tangible {
+	var fetched Tangible
+	fetched, err := NewPost(input, source)
+
+	if errors.Is(err, ErrWrongType) {
+		fetched, err = NewActor(input, source)
+	}
+
+	if errors.Is(err, ErrWrongType) {
+		fetched, err = NewActivity(input, source)
+	}
+
+	if errors.Is(err, ErrWrongType) {
+		return NewFailure(err)
+	}
+
+	if err != nil {
+		return NewFailure(err)
+	}
+
+	return fetched
+}
+
+/*
+	"Shorthand" just means individual strings are converted into Links
+*/
+func getLinksShorthand(o object.Object, key string) ([]*Link, error) {
+	list, err := o.GetList(key)
+	if err != nil {
+		return nil, err
+	}
+
+	output := make([]*Link, len(list))
+
+	for i, element := range list {
+		switch narrowed := element.(type) {
+		case object.Object:
+			link, err := NewLink(narrowed)
+			if err != nil {
+				return nil, err
+			}
+			output[i] = link
+		case string:
+			link, err := NewLink(object.Object {
+				"type": "Link",
+				"href": narrowed,
+			})
+			if err != nil {
+				return nil, err
+			}
+			output[i] = link
+		default:
+			return nil, fmt.Errorf("can't convert a %T into a Link", element)
+		}
+	}
+	return output, nil
+}
+
+func getBestLinkShorthand(o object.Object, key string, supertype string) (*Link, error) {
+	links, err := getLinksShorthand(o, key)
+	if err != nil {
+		return nil, err
+	}
+	return SelectBestLink(links, supertype)
+}
+
+func getFirstLinkShorthand(o object.Object, key string) (*Link, error) {
+	links, err := getLinksShorthand(o, key)
+	if err != nil {
+		return nil, err
+	}
+	return SelectFirstLink(links)
+}
+
+func getLinks(o object.Object, key string) ([]*Link, error) {
+	list, err := o.GetList(key)
+	if err != nil {
+		return nil, err
+	}
+	links := make([]*Link, len(list))
+	for i, element := range list {
+		link, err := NewLink(element)
+		if err != nil {
+			return nil, err
+		}
+		links[i] = link
+	}
+	return links, nil
+}
+
+func getBestLink(o object.Object, key string, supertype string) (*Link, error) {
+	links, err := getLinks(o, key)
+	if err != nil { return nil, err }
+	return SelectBestLink(links, supertype)
+}

+ 0 - 200
pub/construct.go

@@ -1,200 +0,0 @@
-package pub
-
-import (
-	"errors"
-	"net/url"
-	"strings"
-	"mimicry/jtp"
-	"os"
-	"encoding/json"
-)
-
-const MAX_REDIRECTS = 20
-
-/*
-	Namings:
-	// converts a string (url) or Dict into an Item
-	FetchUnknown: any (a url.URL or Dict) -> Item
-		If input is a string, converts it via:
-			return FetchURL: url.URL -> Item
-		return Construct: Dict -> Item
-
-	// converts user input (webfinger, url, or local file) into Item
-	FetchUserInput: string -> Item
-		If input starts with @, converts it via:
-			ResolveWebfinger: string -> url.URL
-		return FetchURL: url.URL -> Item
-*/
-
-/*
-	Converts a string (url) or Object into an Item
-	source represents where the original came from
-*/
-func FetchUnknown(input any, source *url.URL) (Item, error) {
-	switch narrowed := input.(type) {
-	case string:
-		url, err := url.Parse(narrowed)
-		if err != nil {
-			return nil, err
-		}
-		return FetchURL(url)
-	case map[string]any:
-		return Construct(Object(narrowed), source)
-	default:
-		return nil, errors.New("can't turn non-string, non-Object into Item")
-	}
-}
-
-/*
-	converts a url into a Object
-*/
-func FetchURL(link *url.URL) (Item, error) {
-	var object Object
-	object, err := jtp.Get(
-			link,
-			`application/activity+json,` +
-			`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
-			[]string{
-				"application/activity+json",
-				"application/ld+json",
-				"application/json",
-			},
-			MAX_REDIRECTS,
-		)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return Construct(object, link)
-}
-
-/*
-	converts a Object into an Item
-	source is the url whence the Object came
-*/
-func Construct(object Object, source *url.URL) (Item, error) {
-	kind, err := object.GetString("type")
-	if err != nil {
-		return nil, err
-	}
-
-	id, _ := object.GetURL("id")
-
-	if id != nil {
-		if source == nil {
-			return FetchURL(id)
-		}
-		if (source.Hostname() != id.Hostname()) || len(object) <= 2 {
-			return FetchURL(id)
-		}
-	}
-
-	switch kind {
-	case "Article", "Audio", "Document", "Image", "Note", "Page", "Video":
-		return Post{object}, nil
-
-	// case "Create", "Announce", "Dislike", "Like":
-	//	return Activity(o), nil
-
-	case "Application", "Group", "Organization", "Person", "Service":
-		return Actor{object}, nil
-
-	case "Link":
-		return Link{object}, nil
-
-	case "Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage":
-		return Collection{object, 0}, nil
-
-	default:
-		return nil, errors.New("ActivityPub Type " + kind + " is not supported")
-	}
-}
-
-func FetchUserInput(text string) (Item, error) {
-	if strings.HasPrefix(text, "@") {
-		link, err := ResolveWebfinger(text)
-		if err != nil {
-			return nil, err
-		}
-		return FetchURL(link)
-	}
-
-	if strings.HasPrefix(text, "/") ||
-		strings.HasPrefix(text, "./") ||
-		strings.HasPrefix(text, "../") {
-		file, err := os.Open(text)
-		if err != nil {
-			return nil, err
-		}
-		var object Object
-		json.NewDecoder(file).Decode(&object)
-		return Construct(object, nil)
-	}
-
-	link, err := url.Parse(text)
-	if err != nil {
-		return nil, err
-	}
-	return FetchURL(link)
-}
-
-/*
-	converts a webfinger identifier to a url
-	see: https://datatracker.ietf.org/doc/html/rfc7033
-*/
-func ResolveWebfinger(username string) (*url.URL, error) {
-	username = strings.TrimPrefix(username, "@")
-	split := strings.Split(username, "@")
-	var account, domain string
-	if len(split) != 2 {
-		return nil, errors.New("webfinger address must have a separating @ symbol")
-	}
-	account = split[0]
-	domain = split[1]
-
-	query := url.Values{}
-	query.Add("resource", "acct:" + account + "@" + domain)
-	query.Add("rel", "self")
-
-	link := &url.URL{
-		Scheme: "https",
-		Host: domain,
-		Path: "/.well-known/webfinger",
-		RawQuery: query.Encode(),
-	}
-
-	response, err := jtp.Get(link, "application/jrd+json", []string{"application/jrd+json"}, MAX_REDIRECTS)
-	object := Object(response)
-
-	jrdLinks, err := object.GetList("links")
-	if err != nil {
-		return nil, err
-	}
-
-	var underlyingLink *url.URL = nil
-
-	for _, el := range jrdLinks {
-		jrdLink, ok := el.(Object)
-		if ok {
-			rel, err := jrdLink.GetString("rel")
-			if err != nil { continue }
-			if rel != "self" { continue }
-			mediaType, err := jrdLink.GetMediaType("type")
-			if err != nil { continue }
-			if !mediaType.Matches([]string{"application/jrd+json", "application/json"}) {
-				continue
-			}
-			href, err := jrdLink.GetURL("href")
-			if err != nil { continue }
-			underlyingLink = href
-			break
-		}
-	}
-
-	if underlyingLink == nil {
-		return nil, errors.New("no matching href was found in the links array of " + link.String())
-	}
-
-	return underlyingLink, nil
-}

+ 0 - 18
pub/construct_test.go

@@ -1,18 +0,0 @@
-package pub
-
-import (
-	"mimicry/util"
-	"testing"
-)
-
-func TestFromFile(t *testing.T) {
-	item, err := FetchUserInput("../tests/cases/basic.json")
-	if err != nil { t.Fatal(err) }
-	note, ok := item.(Post)
-	if !ok { t.Fatal("basic.json is not a Post") }
-
-	util.AssertEqual("Note", note.Kind(), t)
-	body, err := note.Body(100)
-	if err != nil { t.Fatal(err) }
-	util.AssertEqual("Hello, World!", body, t)
-}

+ 38 - 0
pub/failure.go

@@ -0,0 +1,38 @@
+package pub
+
+import (
+	"mimicry/style"
+)
+
+type Failure struct {
+	message error
+}
+
+func NewFailure(err error) *Failure {
+	if err == nil {
+		panic("do not create a failure with a nil error")
+	}
+	return &Failure{err}
+}
+
+func (f *Failure) Kind() string { return "failure" }
+
+func (f *Failure) Name() string {
+	return style.Problem(f.message)
+}
+
+func (f *Failure) Preview(width int) string {
+	return f.Name()
+}
+
+func (f *Failure) String(width int) string {
+	return f.Preview(width)
+}
+
+func (f *Failure) Parents(uint) []Tangible {
+	return []Tangible{}
+}
+
+func (f *Failure) Children(uint) ([]Tangible, Container, uint) {
+	return []Tangible{}, nil, 0
+}

+ 22 - 0
pub/interfaces.go

@@ -0,0 +1,22 @@
+package pub
+
+type Any interface {
+	Kind() string
+}
+
+type Tangible interface {
+	Kind() string
+
+	String(width int) string
+	Preview(width int) string
+	Parents(quantity uint) []Tangible
+	Children(quantity uint) ([]Tangible, Container, uint)
+}
+
+type Container interface {
+	Kind() string
+
+	/* result, index of next item, next collection */
+	Harvest(quantity uint, startingAt uint) ([]Tangible, Container, uint)
+	Size() (uint64, error)
+}

+ 0 - 8
pub/item.go

@@ -1,8 +0,0 @@
-package pub
-
-type Item interface {
-	String(width int) (string, error)
-	Preview() (string, error)
-	Kind() string
-	Category() string
-}

+ 99 - 56
pub/link.go

@@ -3,92 +3,129 @@ package pub
 import (
 	"net/url"
 	"errors"
+	"mimicry/object"
+	"mimicry/mime"
+	"fmt"
+	"golang.org/x/exp/slices"
 )
 
 type Link struct {
-	Object
+	kind string
+	mediaType *mime.MediaType
+	mediaTypeErr error
+	uri *url.URL
+	uriErr error
+	alt string
+	altErr error
+	height uint64
+	heightErr error
+	width uint64
+	widthErr error
 }
 
-func (l Link) Kind() string {
-	return "Link"
-}
-func (l Link) Category() string {
-	return "link"
-}
+func NewLink(input any) (*Link, error) {
+	l := &Link{}
 
-func (l Link) Supertype() (string, error) {
-	mediaType, err := l.GetMediaType("mediaType")
-	if err != nil { return "", err }
-	return mediaType.Supertype, nil
-}
+	// TODO: narrow input to o (an object.Object)
+	o, ok := input.(object.Object)
+	if !ok {
+		return nil, fmt.Errorf("can't turn non-object %T into Link", input)
+	}
 
-func (l Link) Subtype() (string, error) {
-	mediaType, err := l.GetMediaType("mediaType")
-	if err != nil { return "", err }
-	return mediaType.Subtype, nil
-}
+	var err error
+	if l.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
 
-func (l Link) URL() (*url.URL, error) {
-	return l.GetURL("href")
-}
+	if !slices.Contains([]string{
+		"Link", "Audio", "Document", "Image", "Video",
+	}, l.kind) {
+		return nil, fmt.Errorf("%w: %s is not a Link", ErrWrongType, l.kind)
+	}
 
-func (l Link) Alt() (string, error) {
-	alt, err := l.GetString("name")
-	if alt == "" || err != nil {
-		alt, err = l.GetString("href")
-		if err != nil { return "", err }
+	if l.kind == "Link" {
+		l.uri, l.uriErr = o.GetURL("href")
+		l.height, l.heightErr = o.GetNumber("height")
+		l.width, l.widthErr = o.GetNumber("width")
+	} else {
+		l.uri, l.uriErr = o.GetURL("url")
+		l.heightErr = object.ErrKeyNotPresent
+		l.widthErr = object.ErrKeyNotPresent
 	}
-	return alt, nil
-}
 
-func (l Link) rating() uint64 {
-	height, err := l.GetNumber("height")
-	if err != nil { height = 1 }
-	width, err := l.GetNumber("width")
-	if err != nil { width = 1 }
-	return height * width
+	l.mediaType, l.mediaTypeErr = o.GetMediaType("mediaType")
+	l.alt, l.altErr = o.GetString("name")
+
+	return l, nil
 }
 
-func (l Link) String(width int) (string, error) {
-	output := ""
+func (l *Link) Kind() string {
+	return l.kind
+}
 
-	if alt, err := l.Alt(); err == nil {
-		output += alt
-	} else if url, err := l.URL(); err == nil {
-		output += url.String()
+func (l *Link) Alt() (string, error) {
+	if l.altErr == nil {
+		return l.alt, nil
+	} else if errors.Is(l.altErr, object.ErrKeyNotPresent) {
+		if l.uriErr == nil {
+			return l.uri.String(), nil
+		} else {
+			return "", l.uriErr
+		}
+	} else {
+		return "", l.altErr
 	}
+}
 
-	if Subtype, err := l.Subtype(); err == nil {
-		output += " (" + Subtype + ")"
+func (l *Link) rating() (uint64, error) {
+	var height, width uint64
+	if l.heightErr == nil {
+		height = l.height
+	} else if errors.Is(l.heightErr, object.ErrKeyNotPresent) {
+		height = 1
+	} else {
+		return 0, l.heightErr
 	}
-
-	return output, nil
+	if l.widthErr == nil {
+		width = l.width
+	} else if errors.Is(l.widthErr, object.ErrKeyNotPresent) {
+		width = 1
+	} else {
+		return 0, l.widthErr
+	}
+	return height * width, nil
 }
 
-func (l Link) Preview() (string, error) {
-	return "todo", nil
+func (l *Link) MediaType() (*mime.MediaType, error) {
+	return l.mediaType, l.mediaTypeErr
 }
 
-func SelectBestLink(links []Link, supertype string) (Link, error) {
+func SelectBestLink(links []*Link, supertype string) (*Link, error) {
 	if len(links) == 0 {
-		return Link{}, errors.New("can't select best link of type " + supertype + "/* from an empty list")
+		return &Link{}, errors.New("can't select best link of type " + supertype + "/* from an empty list")
 	}
 
 	bestLink := links[0]
 
+	// TODO: loop through once and validate errors, then proceed assuming no errors
+
 	for _, thisLink := range links[1:] {
 		var bestLinkSupertypeMatches bool
-		if bestLinkSupertype, err := bestLink.Supertype(); err != nil {
+		if errors.Is(bestLink.mediaTypeErr, object.ErrKeyNotPresent) {
 			bestLinkSupertypeMatches = false
+		} else if bestLink.mediaTypeErr != nil {
+			return nil, bestLink.mediaTypeErr
 		} else {
-			bestLinkSupertypeMatches = bestLinkSupertype == supertype
+			bestLinkSupertypeMatches = bestLink.mediaType.Supertype == supertype
 		}
 
 		var thisLinkSuperTypeMatches bool
-		if thisLinkSupertype, err := thisLink.Supertype(); err != nil {
+		if errors.Is(thisLink.mediaTypeErr, object.ErrKeyNotPresent) {
 			thisLinkSuperTypeMatches = false
+		} else if thisLink.mediaTypeErr != nil {
+			return nil, thisLink.mediaTypeErr
 		} else {
-			thisLinkSuperTypeMatches = thisLinkSupertype == supertype
+			thisLinkSuperTypeMatches = thisLink.mediaType.Supertype == supertype
 		}
 
 		if thisLinkSuperTypeMatches && !bestLinkSupertypeMatches {
@@ -96,18 +133,24 @@ func SelectBestLink(links []Link, supertype string) (Link, error) {
 			continue
 		} else if !thisLinkSuperTypeMatches && bestLinkSupertypeMatches {
 			continue
-		} else if thisLink.rating() > bestLink.rating() {
-			bestLink = thisLink
-			continue
+		} else {
+			thisRating, err := thisLink.rating()
+			if err != nil { return nil, err }
+			bestRating, err := bestLink.rating()
+			if err != nil { return nil, err }
+			if thisRating > bestRating {
+				bestLink = thisLink
+				continue
+			}
 		}
 	}
 
 	return bestLink, nil
 }
 
-func SelectFirstLink(links []Link) (Link, error) {
+func SelectFirstLink(links []*Link) (*Link, error) {
 	if len(links) == 0 {
-		return Link{}, errors.New("can't select first Link from an empty list of links")
+		return &Link{}, errors.New("can't select first Link from an empty list of links")
 	} else {
 		return links[0], nil
 	}

+ 0 - 187
pub/object.go

@@ -1,187 +0,0 @@
-package pub
-
-import (
-	"errors"
-	"net/url"
-	"time"
-	"mimicry/jtp"
-	"mimicry/render"
-	"fmt"
-)
-
-type Object map[string]any
-
-/*
-	These are helper functions that really should be methods, but
-	Go does not allow generic methods.
-*/
-func getPrimitive[T any](o Object, key string) (T, error) {
-	var zero T
-	if value, ok := o[key]; !ok {
-		return zero, fmt.Errorf("object does not contain key \"" + key + "\": %v", o)
-	} else if narrowed, ok := value.(T); !ok {
-		return zero, errors.New("key " + key + " is not of the desired type")
-	} else {
-		return narrowed, nil
-	}
-}
-func getItem[T Item](o Object, key string) (T, error) {
-	value, err := getPrimitive[any](o, key)
-	if err != nil {
-		return *new(T), err
-	}
-	source, _ := o.GetURL("id")
-	fetched, err := FetchUnknown(value, source)
-	if err != nil { return *new(T), err }
-	asT, isT := fetched.(T)
-	if !isT {
-		errors.New("fetched " + key + " is not of the desired type")
-	}
-	return asT, nil
-}
-func getItems[T Item](o Object, key string) ([]T, error) {
-	values, err := o.GetList(key)
-	if err != nil {
-		return nil, err
-	}
-	source, _ := o.GetURL("id")
-	output := make([]T, 0, len(values))
-	for _, el := range values {
-		resolved, err := FetchUnknown(el, source)
-		if err != nil { continue }
-		asT, isT := resolved.(T)
-		if !isT { continue }
-		output = append(output, asT)
-	}
-	return output, nil
-}
-
-/* Various methods for getting basic information from the Object */
-func (o Object) GetString(key string) (string, error) {
-	return getPrimitive[string](o, key)
-}
-func (o Object) GetNumber(key string) (uint64, error) {
-	if number, err := getPrimitive[float64](o, key); err != nil {
-		return 0, err
-	} else {
-		return uint64(number), nil
-	}
-}
-func (o Object) GetObject(key string) (Object, error) {
-	return getPrimitive[Object](o, key)
-}
-func (o Object) GetList(key string) ([]any, error) {
-	if value, err := getPrimitive[any](o, key); err != nil {
-		return nil, err
-	} else if asList, isList := value.([]any); isList {
-		return asList, nil
-	} else {
-		return []any{value}, nil
-	}
-}
-func (o Object) GetTime(key string) (time.Time, error) {
-	if value, err := o.GetString(key); err != nil {
-		return time.Time{}, err
-	} else {
-		return time.Parse(time.RFC3339, value)
-	}
-}
-func (o Object) GetURL(key string) (*url.URL, error) {
-	if value, err := o.GetString(key); err != nil {
-		return nil, err
-	} else {
-		return url.Parse(value)
-	}
-}
-func (o Object) GetMediaType(key string) (*jtp.MediaType, error) {
-	if value, err := o.GetString(key); err != nil {
-		return nil, err
-	} else {
-		return jtp.ParseMediaType(value)
-	}
-}
-/* https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues */
-func (o Object) GetNatural(key string, language string) (string, error) {
-	values, valuesErr := o.GetObject(key+"Map")
-	if valuesErr == nil {
-		if value, err := values.GetString(language); err == nil {
-			return value, nil
-		}
-	}
-	if value, err := o.GetString(key); err == nil {
-		return value, nil
-	}
-	if valuesErr == nil {
-		if value, err := values.GetString("und"); err == nil {
-			return value, nil
-		}
-	}
-	return "", errors.New("natural language key " + key + " is not correctly present in object")
-}
-
-/* Methods for getting various Items from the Object */
-func (o Object) GetActors(key string) ([]Actor, error) {
-	return getItems[Actor](o, key)
-}
-func (o Object) GetPost(key string) (Post, error) {
-	return getItem[Post](o, key)
-}
-// func (o Object) GetActivity(key string) (Activity, error) {
-// 	return getItem[Activity](o, key)
-// }
-func (o Object) GetCollection(key string) (Collection, error) {
-	return getItem[Collection](o, key)
-}
-func (o Object) GetItems(key string) ([]Item, error) {
-	return getItems[Item](o, key)
-}
-
-/*
-	Fetches strings as URLs, converts Posts to Links, and
-		ignores non-Link non-Post non-string elements.
-	Used for `Post.attachment`, `Actor.icon`, etc.
-*/
-func (o Object) GetLinks(key string) ([]Link, error) {
-	values, err := o.GetItems(key)
-	if err != nil {
-		return []Link{}, err
-	}
-	output := make([]Link, 0, len(values))
-	for _, el := range values {
-		switch narrowed := el.(type) {
-		case Link:
-			output = append(output, narrowed)
-		case Post:
-			if link, err := narrowed.Link(); err == nil {
-				output = append(output, link)
-			} else { continue }
-		default: continue
-		}
-	}
-	return output, nil
-}
-
-func (o Object) Has(key string) bool {
-	_, present := o[key]
-	return present
-}
-func (o Object) HasNatural(key string) bool {
-	return o.Has(key) || o.Has(key+"Map")
-}
-
-func (o Object) Render(contentKey string, langKey string, mediaTypeKey string, width int) (string, error) {
-	body, err := o.GetNatural(contentKey, langKey)
-	if err != nil {
-		return "", err
-	}
-	mediaType := &jtp.MediaType{
-		Supertype: "text",
-		Subtype: "html",
-		Full: "text/html",
-	}
-	if o.Has("mediaType") {
-		mediaType, err = o.GetMediaType(mediaTypeKey)
-		if err != nil { return "", err }
-	}
-	return render.Render(body, mediaType.Full, width)
-}

+ 198 - 148
pub/post.go

@@ -6,207 +6,257 @@ import (
 	"time"
 	"mimicry/style"
 	"mimicry/ansi"
+	"mimicry/object"
+	"errors"
+	"mimicry/client"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/mime"
+	"mimicry/render"
 )
 
 type Post struct {
-	Object
+	kind string
+	identifier *url.URL
+
+	title string
+	titleErr error
+	body string
+	bodyErr error
+	mediaType *mime.MediaType
+	mediaTypeErr error
+	link *Link
+	linkErr error
+	created time.Time
+	createdErr error
+	edited time.Time
+	editedErr error
+	parent any
+	parentErr error
+
+	// just as body dies completely if members die,
+	// attachments dies completely if any member dies
+	attachments []*Link
+	attachmentsErr error
+
+	creators []TangibleWithName
+	recipients []TangibleWithName
+	comments *Collection
+	commentsErr error
 }
 
-func (p Post) Kind() (string) {
-	kind, err := p.GetString("type")
-	if err != nil {
-		panic(err)
+func NewPost(input any, source *url.URL) (*Post, error) {
+	p := &Post{}
+	var o object.Object; var err error
+	o, p.identifier, err = client.FetchUnknown(input, source)
+	if err != nil { return nil, err }
+	if p.kind, err = o.GetString("type"); err != nil {
+		return nil, err
 	}
-	return kind
-}
-
-func (p Post) Title() (string, error) {
-	return p.GetNatural("name", "en")
-}
 
-func (p Post) Body(width int) (string, error) {
-	return p.Render("content", "en", "mediaType", width)
-}
-
-func (p Post) Identifier() (*url.URL, error) {
-	return p.GetURL("id")
-}
-
-func (p Post) Created() (time.Time, error) {
-	return p.GetTime("published")
-}
-
-func (p Post) Edited() (time.Time, error) {
-	return p.GetTime("updated")
-}
-
-func (p Post) Category() string {
-	return "post"
-}
+	// TODO: for Lemmy, may have to auto-unwrap Create into a Post
+	if !slices.Contains([]string{
+		"Article", "Audio", "Document", "Image", "Note", "Page", "Video",
+	}, p.kind) {
+		return nil, fmt.Errorf("%w: %s is not a Post", ErrWrongType, p.kind)
+	}
 
-func (p Post) Creators() ([]Actor, error) {
-	return p.GetActors("attributedTo")
-}
+	p.title, p.titleErr = o.GetNatural("name", "en")
+	p.body, p.bodyErr = o.GetNatural("content", "en")
+	p.mediaType, p.mediaTypeErr = o.GetMediaType("mediaType")
+	p.created, p.createdErr = o.GetTime("published")
+	p.edited, p.editedErr = o.GetTime("updated")
+	p.parent, p.parentErr = o.GetAny("inReplyTo")
+	
+	if p.kind == "Image" || p.kind == "Audio" || p.kind == "Video" {
+		p.link, p.linkErr = getBestLinkShorthand(o, "url", strings.ToLower(p.kind))
+	} else {
+		p.link, p.linkErr = getFirstLinkShorthand(o, "url")
+	}
 
-func (p Post) Recipients() ([]Actor, error) {
-	return p.GetActors("to")
+	// TODO: perhaps the actor fraud check should occur right here--if
+	// all fail, the entire constructor fails? Probably not, what if
+	// one fails because of the protocol, another fails because of fraud
+	// check, I probably want to show the whole thing
+	p.creators = getActors(o, "attributedTo", p.identifier)
+	p.recipients = getActors(o, "audience", p.identifier)
+	p.attachments, p.attachmentsErr = getLinks(o, "attachment")
+
+	// TODO: in the future, I may want to pass an assertion to the collection
+	// asserting that the posts therein do reply to this post
+	p.comments, p.commentsErr = getCollection(o, "replies", p.identifier)
+	if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
+		p.comments, p.commentsErr = getCollection(o, "comments", p.identifier)
+	}
+	return p, nil
 }
 
-func (p Post) Attachments() ([]Link, error) {
-	return p.GetLinks("attachment")
+func (p *Post) Kind() (string) {
+	return p.kind
 }
 
-func (p Post) Comments() (Collection, error) {
-	if p.Has("comments") && !p.Has("replies") {
-		return p.GetCollection("comments")
+func (p *Post) Children(quantity uint) ([]Tangible, Container, uint) {
+	if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
+		return []Tangible{}, nil, 0
+	}
+	if p.commentsErr != nil {
+		return []Tangible{
+			NewFailure(p.commentsErr),
+		}, nil, 0
 	}
-	return p.GetCollection("replies")
+	return p.comments.Harvest(quantity, 0)
 }
 
-func (p Post) Link() (Link, error) {
-	values, err := p.GetList("url")
-	if err != nil {
-		return Link{}, err
+func (p *Post) Parents(quantity uint) []Tangible {
+	if quantity == 0 {
+		return []Tangible{}
 	}
-	
-	links := make([]Link, 0, len(values))
-
-	for _, el := range values {
-		switch narrowed := el.(type) {
-		case string:
-			link := Link{Object{
-				"type": "Link",
-				"href": narrowed,
-			}}
-			if name, err := p.GetNatural("name", "en"); err == nil {
-				link.Object["name"] = name
-			}
-			if !p.HasNatural("content") {
-				if mediaType, err := p.GetString("mediaType"); err == nil {
-					link.Object["mediaType"] = mediaType
-				}
-			}
-			links = append(links, link)
-		case Object:
-			source, _ := p.GetURL("id")
-			item, err := Construct(narrowed, source)
-			if err != nil { continue }
-			if asLink, isLink := item.(Link); isLink {
-				links = append(links, asLink)
-			}
-		}
+	if errors.Is(p.parentErr, object.ErrKeyNotPresent) {
+		return []Tangible{}
 	}
-
-	kind := p.Kind()
-	switch kind {
-	case "Audio", "Image", "Video":
-		return SelectBestLink(links, strings.ToLower(kind))
-	default:
-		return SelectFirstLink(links)
+	if p.parentErr != nil {
+		return []Tangible{NewFailure(p.parentErr)}
 	}
+	fetchedParent, fetchedParentErr := NewPost(p.parent, p.identifier)
+	if fetchedParentErr != nil {
+		return []Tangible{NewFailure(fetchedParentErr)}
+	}
+	return append([]Tangible{fetchedParent}, fetchedParent.Parents(quantity - 1)...)
 }
 
-func (p Post) header(width int) (string, error) {
+func (p *Post) header(width int) string {
 	output := ""
 
-	if title, err := p.Title(); err == nil {
-		output += style.Bold(title) + "\n"
+	if p.titleErr == nil {
+		output += style.Bold(p.title) + "\n"
+	} else if !errors.Is(p.titleErr, object.ErrKeyNotPresent) {
+		output += style.Problem(fmt.Errorf("failed to get title: %w", p.titleErr)) + "\n"
 	}
 
-	output += style.Color(p.Kind())
+	output += style.Color(strings.ToLower(p.kind))
 
-	if creators, err := p.Creators(); err == nil {
-		names := []string{}
-		for _, creator := range creators {
-			if name, err := creator.InlineName(); err == nil {
-				names = append(names, style.Link(name))
+	if len(p.creators) > 0 {
+		output += " by "
+		for i, creator := range p.creators {
+			output += style.Color(creator.Name())
+			if i != len(p.creators) - 1 {
+				output += ", "
 			}
 		}
-		if len(names) > 0 {
-			output += " by " + strings.Join(names, ", ")
-		}
 	}
-
-	if recipients, err := p.Recipients(); err == nil {
-		names := []string{}
-		for _, recipient := range recipients {
-			if name, err := recipient.InlineName(); err == nil {
-				names = append(names, style.Link(name))
+	if len(p.recipients) > 0 {
+		output += " to "
+		for i, recipient := range p.recipients {
+			output += style.Color(recipient.Name())
+			if i != len(p.recipients) - 1 {
+				output += ", "
 			}
 		}
-		if len(names) > 0 {
-			output += " to " + strings.Join(names, ", ")
-		}
 	}
 
-	if created, err := p.Created(); err == nil {
-		const timeFormat = "3:04 pm on 2 Jan 2006"
-		output += " at " + style.Color(created.Format(timeFormat))
-		// if edited, err := p.Updated(); err == nil {
-		// 	output += " (edited at " + style.Color(edited.Format(timeFormat)) + ")"
-		// }
+	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))
 	}
 
-	return ansi.Wrap(output, width), nil
+	return ansi.Wrap(output, width)
 }
 
-func (p Post) String(width int) (string, error) {
-	output := ""
+func (p *Post) center(width int) (string, bool) {
+	if errors.Is(p.bodyErr, object.ErrKeyNotPresent) {
+		return "", false
+	}
+	if p.bodyErr != nil {
+		return ansi.Wrap(style.Problem(p.bodyErr), width), true
+	}
 
-	if header, err := p.header(width - 4); err == nil {
-		output += ansi.Indent(header, "  ", true)
-		output += "\n\n"
+	mediaType := p.mediaType
+	if errors.Is(p.mediaTypeErr, object.ErrKeyNotPresent) {
+		mediaType = mime.Default()
+	} else if p.mediaTypeErr != nil {
+		return ansi.Wrap(style.Problem(p.mediaTypeErr), width), true
 	}
 
-	if body, err := p.Body(width - 8); err == nil {
-		output += ansi.Indent(body, "    ", true)
-		output += "\n\n"
+	rendered, err := render.Render(p.body, mediaType.Essence, width)
+	if err != nil {
+		return style.Problem(err), true
 	}
+	return rendered, true
+}
 
-	if attachments, err := p.Attachments(); err == nil {
-		if len(attachments) > 0 {
-			section := "Attachments:\n"
-			names := []string{}
-			for _, attachment := range attachments {
-				if name, err := attachment.String(width); err == nil {
-					names = append(names, style.Link(name))
-				}
-			}
-			section += ansi.Indent(ansi.Wrap(strings.Join(names, "\n"), width - 4), "  ", true)
-			section = ansi.Indent(ansi.Wrap(section, width - 2), "  ", true)
-			output += section
-			output += "\n"
-		}
+func (p *Post) supplement(width int) (string, bool) {
+	if errors.Is(p.attachmentsErr, object.ErrKeyNotPresent) {
+		return "", false
+	}
+	if p.attachmentsErr != nil {
+		return ansi.Wrap(style.Problem(fmt.Errorf("failed to load attachments: %w", p.attachmentsErr)), width), true
+	}
+	if len(p.attachments) == 0 {
+		return "", false
 	}
 
-	if comments, err := p.Comments(); err == nil {
-		if size, err := comments.Size(); err == nil {
-			output += ansi.Indent(ansi.Wrap("with " + style.Color(size + " comments"), width - 2), "  ", true)
-			output += "\n\n"
+	output := ""
+	for _, attachment := range p.attachments {
+		if output != "" { output += "\n" }
+		link, err := NewLink(attachment)
+		if err != nil {
+			output += style.Problem(err)
+			continue
 		}
-		if section, err := comments.String(width); err == nil {
-			output += section + "\n"
-		} else {
-			return "", err
+		alt, err := link.Alt()
+		if err != nil {
+			output += style.Problem(err)
+			continue
 		}
+		output += style.LinkBlock(alt)
 	}
+	return ansi.Wrap(output, width), true
+}
 
-	return output, nil
+func (p *Post) footer(width int) string {
+	if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
+		return style.Color("comments disabled")
+	} else if p.commentsErr != nil {
+		return style.Color("comments enabled")
+	} else if quantity, err := p.comments.Size(); errors.Is(err, object.ErrKeyNotPresent) {
+		return style.Color("comments enabled")
+	} else if err != nil {
+		return style.Problem(err)
+	} else if quantity == 1 {
+		return style.Color(fmt.Sprintf("%d comment", quantity))
+	} else {
+		return style.Color(fmt.Sprintf("%d comments", quantity))
+	}
 }
 
-func (p Post) Preview() (string, error) {
-	output := ""
-	width := 100
+func (p Post) String(width int) string {
+	output := p.header(width)
 
-	if header, err := p.header(width); err == nil {
-		output += header
-		output += "\n"
+	if body, present := p.center(width - 4); present {
+		output += "\n\n" + ansi.Indent(body, "  ", true)
 	}
 
-	if body, err := p.Body(width); err == nil {
-		output += ansi.Snip(body, width, 4, style.Color("\u2026"))
-		output += "\n"
+	if attachments, present := p.supplement(width - 4); present {
+		output += "\n\n" + ansi.Indent(attachments, "  ", true)
+	}
+	
+	output += "\n\n" + p.footer(width)
+
+	return output
+}
+
+func (p *Post) Preview(width int) string {
+	output := p.header(width)
+
+	if body, present := p.center(width); present {
+		if attachments, present := p.supplement(width); present {
+			output += "\n" + ansi.Snip(body + "\n" + attachments, width, 4, style.Color("\u2026"))
+		} else {
+			output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
+		}
 	}
 
-	return output, nil
+	output += "\n" + p.footer(width)
+	return output
 }

+ 28 - 0
pub/user-input.go

@@ -0,0 +1,28 @@
+package pub
+
+import (
+	"strings"
+	"mimicry/client"
+)
+
+func FetchUserInput(text string) Any {
+	if strings.HasPrefix(text, "@") {
+		link, err := client.ResolveWebfinger(text)
+		if err != nil {
+			return NewFailure(err)
+		}
+		return NewTangible(link, nil)
+	}
+
+	if strings.HasPrefix(text, "/") ||
+		strings.HasPrefix(text, "./") ||
+		strings.HasPrefix(text, "../") {
+		object, err := client.FetchFromFile(text)
+		if err != nil {
+			return NewFailure(err)
+		}
+		return NewTangible(object, nil)
+	}
+
+	return NewTangible(text, nil)
+}

+ 115 - 0
ui/ui.go

@@ -0,0 +1,115 @@
+package ui
+
+import (
+	"mimicry/pub"
+	"mimicry/ansi"
+	"mimicry/feed"
+	"fmt"
+	"log"
+)
+
+type State struct {
+	/* the 0 index is special; it is rendered in full, not as a preview.
+	   the others are all rendered as previews. negative indexes represent
+	   parents of the 0th element (found via `inReplyTo`) and positive
+	   elements represent children (found via the pertinent collection,
+	   e.g. `replies` for a post or `outbox` for an actor) */
+	feed *feed.Feed
+	index int
+	context int
+
+	page pub.Container
+	basepoint uint
+}
+
+func (s *State) View(width int, height uint) string {
+	//return s.feed.Get(0).String(width)
+	var top, center, bottom string
+	//TODO: this should be bounded based on size of feed
+	for i := s.index - s.context; i <= s.index + s.context; i++ {
+		if !s.feed.Contains(i) {
+			continue
+		}
+		var serialized string
+		if i == 0 {
+			serialized = s.feed.Get(i).String(width - 4)
+			log.Printf("%d\n", len(serialized))
+		} else if i > 0 {
+			serialized = "╰ " + ansi.Indent(s.feed.Get(i).Preview(width - 4), "  ", false)
+		} else {
+			serialized = s.feed.Get(i).Preview(width - 4)
+		}
+		if i == s.index {
+			center = ansi.Indent(serialized, "┃ ", true)
+		} else if i < s.index {
+			if top != "" { top += "\n" }
+			top += ansi.Indent(serialized + "\n│", "  ", true)
+		} else {
+			if bottom != "" { bottom = "\n" + bottom }
+			bottom = ansi.Indent("│\n" + serialized, "  ", true) + bottom
+		}
+	}
+	log.Printf("%s\n", center)
+	return ansi.CenterVertically(top, center, bottom, height)
+}
+
+func (s *State) Update(input byte) {
+	/* Interesting problem, but you will succeed! */
+	switch input {
+	case 'k': // up
+		mayNeedLoading := s.index - 1 - s.context
+		if !s.feed.Contains(mayNeedLoading) {
+			if s.feed.Contains(mayNeedLoading - 1) {
+				s.feed.Prepend(s.feed.Get(mayNeedLoading - 1).Parents(1))
+			}
+		}
+
+		if s.feed.Contains(s.index - 1) {
+			s.index -= 1
+		}
+	case 'j': // down
+		mayNeedLoading := s.index + 1 + s.context
+		if !s.feed.Contains(mayNeedLoading) {
+			if s.page != nil {
+				var children []pub.Tangible
+				children, s.page, s.basepoint = s.page.Harvest(1, s.basepoint)
+				s.feed.Append(children)
+			}
+		}
+
+		if s.feed.Contains(s.index + 1) {
+			s.index += 1
+		}
+	}
+	// TODO: the catchall down here will be to look at s.feed.Get(s.index).References()
+	// for urls to switch to
+}
+
+func (s *State) SwitchTo(item pub.Any)  {
+	switch narrowed := item.(type) {
+	case pub.Tangible:
+		s.feed = feed.Create(narrowed)
+		s.feed.Prepend(narrowed.Parents(uint(s.context)))
+		var children []pub.Tangible
+		children, s.page, s.basepoint = narrowed.Children(uint(s.context))
+		s.feed.Append(children)
+	case pub.Container:
+		var children []pub.Tangible
+		children, s.page, s.basepoint = narrowed.Harvest(uint(s.context), 0)
+		s.feed = feed.CreateAndAppend(children)
+	default:
+		panic(fmt.Sprintf("unrecognized non-Tangible non-Container: %T", item))
+	}
+}
+
+func Start(input string) *State {
+	item := pub.FetchUserInput(input)
+	log.Printf("%v\n", item)
+	s := &State{
+		feed: &feed.Feed{},
+		index: 0,
+		context: 1,
+	}
+	s.SwitchTo(item)
+	return s
+}