Browse Source

Clean up the base methods for querying the JSON object

Benton Edmondson 2 years ago
parent
commit
7a1d4b85a8
13 changed files with 412 additions and 624 deletions
  1. 0 2
      go.mod
  2. 0 2
      go.sum
  3. 24 29
      kinds/actor.go
  4. 33 36
      kinds/collection.go
  5. 82 151
      kinds/construct.go
  6. 0 21
      kinds/content.go
  7. 0 256
      kinds/extractor.go
  8. 8 0
      kinds/item.go
  9. 20 33
      kinds/link.go
  10. 187 0
      kinds/object.go
  11. 53 52
      kinds/post.go
  12. 5 7
      main.go
  13. 0 35
      preamble/preamble.go

+ 0 - 2
go.mod

@@ -6,5 +6,3 @@ require (
 	github.com/yuin/goldmark v1.5.4
 	golang.org/x/net v0.8.0
 )
-
-require golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0

+ 0 - 2
go.sum

@@ -1,6 +1,4 @@
 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-20230310171629-522b1b587ee0 h1:LGJsf5LRplCck6jUCH3dBL2dmycNruWNF5xugkSlfXw=
-golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
 golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=

+ 24 - 29
kinds/actor.go

@@ -4,26 +4,27 @@ import (
 	"strings"
 	"net/url"
 	"mimicry/style"
-	"mimicry/render"
 )
 
-type Actor Dict
-
-func (a Actor) Raw() Dict {
-	return a
+type Actor struct {
+	Object
 }
 
-func (a Actor) Kind() (string, error) {
-	kind, err := Get[string](a, "type")
-	return strings.ToLower(kind), err
+func (a Actor) Kind() string {
+	kind, err := a.GetString("type")
+	if err != nil {
+		panic(err)
+	}
+	return strings.ToLower(kind)
 }
 
 func (a Actor) Name() (string, error) {
-	name, err := GetNatural(a, "name", "en")
-	if err != nil {
-		name, err = Get[string](a, "preferredUsername")
+	if a.Has("preferredUsername") && !a.HasNatural("name") {
+		name, err := a.GetString("preferredUsername")
+		if err != nil { return "", err }
+		return "@" + name, nil
 	}
-	return name, err
+	return a.GetNatural("name", "en")
 }
 
 func (a Actor) InlineName() (string, error) {
@@ -31,18 +32,17 @@ func (a Actor) InlineName() (string, error) {
 	if err != nil {
 		return "", err
 	}
+	kind := a.Kind()
+	var suffix string
 	id, err := a.Identifier()
-	if err != nil {
-		return "", err
-	}
-	kind, err := a.Kind()
-	if err != nil {
-		return "", err
-	}
-	if kind == "person" {
-		return name + " (" + id.Hostname() + ")", nil
+	if err == nil {
+		if kind == "person" {
+			suffix = "(" + id.Hostname() + ")"
+		} else {
+			suffix = "(" + id.Hostname() + ", " + kind + ")"
+		}
 	}
-	return name + " (" + id.Hostname() + ", " + kind + ")", nil
+	return name + " " + suffix, nil
 }
 
 func (a Actor) Category() string {
@@ -50,16 +50,11 @@ func (a Actor) Category() string {
 }
 
 func (a Actor) Identifier() (*url.URL, error) {
-	return GetURL(a, "id")
+	return a.GetURL("id")
 }
 
 func (a Actor) Bio() (string, error) {
-	body, err := GetNatural(a, "summary", "en")
-	mediaType, err := Get[string](a, "mediaType")
-	if err != nil {
-		mediaType = "text/html"
-	}
-	return render.Render(body, mediaType, 80)
+	return a.Render("summary", "en", "mediaType", 80)
 }
 
 func (a Actor) String(width int) (string, error) {

+ 33 - 36
kinds/collection.go

@@ -7,19 +7,16 @@ import (
 )
 
 type Collection struct {
-	page Dict
+	Object
 
 	// index *within the current page*
 	index int
 }
 
-func (c Collection) Raw() Dict {
-	return c.page
-}
-
-func (c Collection) Kind() (string, error) {
-	kind, err := Get[string](c.page, "type")
-	return strings.ToLower(kind), err
+func (c Collection) Kind() string {
+	kind, err := c.GetString("type")
+	if err != nil { panic(err) }
+	return strings.ToLower(kind)
 }
 
 func (c Collection) Category() string {
@@ -27,7 +24,7 @@ func (c Collection) Category() string {
 }
 
 func (c Collection) Identifier() (*url.URL, error) {
-	return GetURL(c.page, "id")
+	return c.GetURL("id")
 }
 
 func (c Collection) String(width int) (string, error) {
@@ -42,6 +39,8 @@ func (c Collection) String(width int) (string, error) {
 		}
 
 		if err != nil {
+			// TODO: add a beautiful message here saying
+			// failed to load comment: <error>
 			c.Next()
 			continue
 		}
@@ -52,7 +51,6 @@ func (c Collection) String(width int) (string, error) {
 		}
 
 		elements = append(elements, output)
-
 		c.Next()
 	}
 	
@@ -60,79 +58,78 @@ func (c Collection) String(width int) (string, error) {
 }
 
 func (c Collection) Size() (string, error) {
-	value, err := Get[float64](c.page, "totalItems")
+	value, err := c.GetNumber("totalItems")
 	if err != nil {
 		return "", err
 	}
-	return strconv.Itoa(int(value)), nil
+	return strconv.FormatUint(value, 10), nil
 }
 
 func (c Collection) items() []any {
-	itemsList, itemsErr := Get[[]any](c.page, "items")
-	if itemsErr == nil {
-		return itemsList
+	if c.Has("items") {
+		if list, err := c.GetList("items"); err == nil {
+			return list
+		} else {
+			return []any{}
+		}
 	}
-	orderedItemsList, orderedItemsErr := Get[[]any](c.page, "orderedItems")
-	if orderedItemsErr == nil {
-		return orderedItemsList
+	if c.Has("orderedItems") {
+		if list, err := c.GetList("orderedItems"); err == nil {
+			return list
+		} else {
+			return []any{}
+		}
 	}
-
 	return []any{}
 }
 
-func (c *Collection) Next() (Content, error) {
+func (c *Collection) Next() (Item, error) {
 	c.index += 1
 	return c.Current()
 }
 
-func (c *Collection) Previous() (Content, error) {
+func (c *Collection) Previous() (Item, error) {
 	c.index -= 1
 	return c.Current()
 }
 
-/* This return type is a Option<Result<Content>>
+/* 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() (Content, error) {
+func (c *Collection) Current() (Item, error) {
 	items := c.items()
 	if len(items) == 0 {
-		kind, kindErr := c.Kind()
-		if kindErr != nil {
-			return nil, nil
-		}
-
+		kind := c.Kind()
 		/* If it is a collection, get the first page */
 		if kind == "collection" || kind == "orderedcollection" {
-			first, firstErr := GetItem[Collection](c.page, "first")
+			first, firstErr := c.GetCollection("first")
 			if firstErr != nil {
 				return nil, nil
 			}
-			c.page = first.page
+			c.Object = first.Object
 			c.index = 0
 			return c.Current()
 		}
 	}
 
-	/* At this point we know items are present */
-
 	/* This means we are beyond the end of this page */
 	if c.index >= len(items) {
-		next, err := GetItem[Collection](c.page, "next")
+		next, err := c.GetCollection("next")
 		if err != nil {
 			return nil, nil
 		}
-		c.page = next.page
+		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 := GetItem[Collection](c.page, "prev")
+		prev, err := c.GetCollection("prev")
 		if err != nil {
 			return nil, nil
 		}
-		c.page = prev.page
+		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

+ 82 - 151
kinds/construct.go

@@ -4,20 +4,12 @@ import (
 	"errors"
 	"net/url"
 	"strings"
-	"net/http"
-	"io/ioutil"
-	"encoding/json"
-	"fmt"
+	"mimicry/jtp"
 )
 
-/*
-	TODO: updated plan:
-	I need a function which accepts a string (url) or dict and converts
-	it into an Item (Currently under GetContent)
-
-	I need another function which accepts a string (webfinger or url) and converts
-	it into an Item (currently under FetchUnkown)
+const MAX_REDIRECTS = 20
 
+/*
 	Namings:
 	// converts a string (url) or Dict into an Item
 	FetchUnknown: any (a url.URL or Dict) -> Item
@@ -32,209 +24,148 @@ import (
 		return FetchURL: url.URL -> Item
 */
 
-var client = &http.Client{}
-
-const requiredContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
-const optionalContentType = "application/activity+json"
-
-func FetchUnknown(input any, source *url.URL) (Content, error) {
+/*
+	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:
-		// TODO: detect the 3 `Public` identifiers and error on them
 		url, err := url.Parse(narrowed)
 		if err != nil {
 			return nil, err
 		}
 		return FetchURL(url)
-	case Dict:
-		return Construct(narrowed, source)
+	case map[string]any:
+		return Construct(Object(narrowed), source)
 	default:
-		return nil, errors.New("Can't resolve non-string, non-Dict into Item.")
+		return nil, errors.New("can't turn non-string, non-Object into Item")
 	}
 }
 
-func FetchURL(url *url.URL) (Content, error) {
-	link := url.String()
-
-	req, err := http.NewRequest("GET", link, nil) // `nil` is body
-	if err != nil {
-		return nil, err
-	}
-
-	// add the accept header, some servers only respond if the optional
-	// content type is included as well
-	// § 3.2
-	req.Header.Add("Accept", fmt.Sprintf("%s, %s", requiredContentType, optionalContentType))
+/*
+	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,
+		)
 
-	resp, err := client.Do(req)
 	if err != nil {
 		return nil, err
 	}
 
-	defer resp.Body.Close()
-	body, err := ioutil.ReadAll(resp.Body)
-
-	// GNU Social servers return 202 (with a correct body) instead of 200
-	if resp.StatusCode != 200 && resp.StatusCode != 202 {
-		return nil, errors.New("The server returned a status code of " + resp.Status)
-	}
-
-	// TODO: delete the pointless first if right here
-	// TODO: for the sake of static servers, accept application/json
-	//		 as well iff it contains the @context key (but double check
-	//		 that it is absolutely necessary)
-	if contentType := resp.Header.Get("Content-Type"); contentType == "" {
-		return nil, errors.New("The server's response did not contain a content type")
-
-	// TODO: accept application/ld+json, application/json, and application/activity+json as responses
-	} else if !strings.Contains(contentType, requiredContentType) && !strings.Contains(contentType, optionalContentType) {
-		return nil, errors.New("The server responded with the invalid content type of " + contentType)
-	}
-
-	var unstructured map[string]any
-	if err := json.Unmarshal(body, &unstructured); err != nil {
-		return nil, err
-	}
-
-	return Construct(unstructured, url)
+	return Construct(object, link)
 }
 
-// TODO, add a verbose debugging output mode
-// to debug problems that arise with this thing
-// looping too much and whatnot
-
-// `unstructured` is the JSON to construct from,
-// source is where the JSON was received from,
-// used to ensure the reponse is trustworthy
-func Construct(unstructured Dict, source *url.URL) (Content, error) {
-	kind, err := Get[string](unstructured, "type")
+/*
+	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
 	}
 
-	// this requirement should be removed, and the below check
-	// should be checking if only type or only type and id
-	// are present on the element
-	hasIdentifier := true
-	id, err := GetURL(unstructured, "id")
-	if err != nil {
-		hasIdentifier = false
-	}
+	id, _ := object.GetURL("id")
 
-	// if the JSON came from a source (e.g. inline in another collection), with a
-	// different hostname than its ID, refetch
-	// if the JSON only has two keys (type and id), refetch
-	if (source != nil && id != nil) {
-		if (source.Hostname() != id.Hostname()) || (len(unstructured) <= 2 && hasIdentifier) {
+	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":
-		// TODO: figure out the way to do this directly
-		post := Post{}
-		post = unstructured
-		return post, nil
+		return Post{object}, nil
 
-	// case "Create":
-	// 	fallthrough
-	// case "Announce":
-	// 	fallthrough
-	// case "Dislike":
-	// 	fallthrough
-	// case "Like":
-	// 	fallthrough
-	// case "Question":
-	// 	return Activity{unstructured}, nil
+	// case "Create", "Announce", "Dislike", "Like":
+	//	return Activity(o), nil
 
 	case "Application", "Group", "Organization", "Person", "Service":
-		// TODO: nicer way to do this?
-		actor := Actor{}
-		actor = unstructured
-		return actor, nil
+		return Actor{object}, nil
 
 	case "Link":
-		link := Link{}
-		link = unstructured
-		return link, nil
+		return Link{object}, nil
 
 	case "Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage":
-		collection := Collection{}
-		collection = Collection{unstructured, 0}
-		return collection, nil
+		return Collection{object, 0}, nil
 
 	default:
-		return nil, errors.New("Object of Type " + kind + " unsupported")
+		return nil, errors.New("ActivityPub Type " + kind + " is not supported")
 	}
 }
 
-func FetchUserInput(text string) (Content, error) {
+func FetchUserInput(text string) (Item, error) {
 	if strings.HasPrefix(text, "@") {
 		link, err := ResolveWebfinger(text)
 		if err != nil {
 			return nil, err
 		}
 		return FetchURL(link)
-	} else {
-		link, err := url.Parse(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 dictionary Dict
+	// 	json.NewEncoder(file).Decode(&dictionary)
+	// 	return Construct(dictionary, 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) {
-	// description of WebFinger: https://www.rfc-editor.org/rfc/rfc7033.html
-
 	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")
-	} else {
-		account = split[0]
-		domain = split[1]
 	}
+	account = split[0]
+	domain = split[1]
 
 	query := url.Values{}
-	query.Add("resource", fmt.Sprintf("acct:%s@%s", account, domain))
+	query.Add("resource", "acct:" + account + "@" + domain)
 	query.Add("rel", "self")
 
-	link := url.URL{
+	link := &url.URL{
 		Scheme: "https",
 		Host: domain,
 		Path: "/.well-known/webfinger",
 		RawQuery: query.Encode(),
 	}
 
-	req, err := http.NewRequest("GET", link.String(), nil) // `nil` is body
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	defer resp.Body.Close()
-	body, err := ioutil.ReadAll(resp.Body)
-
-	if resp.StatusCode != 200 {
-		return nil, errors.New(fmt.Sprintf("the server responded to the WebFinger query %s with %s", link.String(), resp.Status))
-	} else if contentType := resp.Header.Get("Content-Type"); !strings.Contains(contentType, "application/jrd+json") && !strings.Contains(contentType, "application/json") {
-		return nil, errors.New("the server responded to the WebFinger query with invalid Content-Type " + contentType)
-	}
-
-	var jrd Dict
-	if err := json.Unmarshal(body, &jrd); err != nil {
-		return nil, err
-	}
+	response, err := jtp.Get(link, "application/jrd+json", []string{"application/jrd+json"}, MAX_REDIRECTS)
+	object := Object(response)
 
-	jrdLinks, err := GetList(jrd, "links")
+	jrdLinks, err := object.GetList("links")
 	if err != nil {
 		return nil, err
 	}
@@ -242,17 +173,17 @@ func ResolveWebfinger(username string) (*url.URL, error) {
 	var underlyingLink *url.URL = nil
 
 	for _, el := range jrdLinks {
-		jrdLink, ok := el.(Dict)
+		jrdLink, ok := el.(Object)
 		if ok {
-			rel, err := Get[string](jrdLink, "rel")
+			rel, err := jrdLink.GetString("rel")
 			if err != nil { continue }
 			if rel != "self" { continue }
-			mediaType, err := Get[string](jrdLink, "type")
+			mediaType, err := jrdLink.GetMediaType("type")
 			if err != nil { continue }
-			if !strings.Contains(mediaType, requiredContentType) && !strings.Contains(mediaType, optionalContentType) {
+			if !mediaType.Matches([]string{"application/jrd+json", "application/json"}) {
 				continue
 			}
-			href, err := GetURL(jrdLink, "href")
+			href, err := jrdLink.GetURL("href")
 			if err != nil { continue }
 			underlyingLink = href
 			break

+ 0 - 21
kinds/content.go

@@ -1,21 +0,0 @@
-package kinds
-
-import (
-	"net/url"
-)
-
-// TODO: rename to Item
-// TODO: a collection should probably not be an item
-type Content interface {
-	String(width int) (string, error)
-	Preview() (string, error)
-	Kind() (string, error)
-	Category() string
-
-	// if the id field is absent or nil, then
-	// this should return (nil, nil),
-	// if it is present and malformed, then use
-	// an error
-	Identifier() (*url.URL, error)
-	Raw() Dict
-}

+ 0 - 256
kinds/extractor.go

@@ -1,256 +0,0 @@
-package kinds
-
-import (
-	"errors"
-	"net/url"
-	"time"
-)
-
-// TODO: rename this file to dictionary.go
-// TODO: rename Dict to Dictionary
-
-// TODO: add a HasContent method. This is used when checking if
-// content exists, so Actors can apply mediaType to summary,
-// and Posts can apply it to url. In other situations (items vs
-// orderedItems, name vs preferredName) attempting to fallback
-// is always better than just failing, and for (absence of next/prev)
-// if I don't have next, I have no other option, so it is 
-// effectively the end of the line
-
-// TODO: read through the spec's discussion on security
-
-type Dict = map[string]any
-
-func Get[T any](o Dict, key string) (T, error) {
-	var zero T
-	if value, ok := o[key]; !ok {
-		return zero, errors.New("Object does not contain key " + key)
-	} else if narrowed, ok := value.(T); !ok {
-		return zero, errors.New("Key " + key + " is not of the desired type")
-	} else {
-		return narrowed, nil
-	}
-}
-
-// some fields have "natural language values" meaning that I should check
-// `contentMap[language]`, followed by `content`, followed by `contentMap["und"]`
-// to find, e.g., the content of the post
-// https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues
-func GetNatural(o Dict, key string, language string) (string, error) {
-	values, valuesErr := Get[Dict](o, key+"Map")
-
-	if valuesErr == nil {
-		if value, err := Get[string](values, language); err == nil {
-			return value, nil
-		}
-	}
-
-	if value, err := Get[string](o, key); err == nil {
-		return value, nil
-	}
-
-	if valuesErr == nil {
-		if value, err := Get[string](values, "und"); err == nil {
-			return value, nil
-		}
-	}
-
-	return "", errors.New("Natural language key " + key + " is not correctly present in object")
-}
-
-// there may be a nice way to extract this logic out but for now it doesn't matter
-func GetTime(o Dict, key string) (time.Time, error) {
-	if value, err := Get[string](o, key); err != nil {
-		return time.Time{}, err
-	} else {
-		return time.Parse(time.RFC3339, value)
-	}
-}
-func GetURL(o Dict, key string) (*url.URL, error) {
-	if value, err := Get[string](o, key); err != nil {
-		return nil, err
-	} else {
-		return url.Parse(value)
-	}
-}
-
-// TODO: this needs to be switched over to using `GetItem`
-/*
-	`GetContent`
-	For a given key, return all values of type T.
-	Strings are interpreted as URLs and fetched.
-	The Public address representations mentioned
-	in §5.6 are ignored.
-	
-	Used for `Post.attributedTo` and `Post.inReplyTo`,
-	for instance.
-*/
-func GetContent[T Content](d Dict, key string) ([]T, error) {
-	values, err := GetList(d, key)
-	if err != nil {
-		return nil, err
-	}
-	
-	output := []T{}
-	
-	for _, el := range values {
-		switch narrowed := el.(type) {
-		case Dict:
-			// TODO: if source is absent, must refetch
-			source, err := GetURL(d, "id")
-			if err != nil { continue }
-			resolved, err := Construct(narrowed, source)
-			if err != nil { continue }
-			asT, isT := resolved.(T)
-			if !isT { continue }
-			output = append(output, asT)
-		case string:
-			// §5.6
-			if narrowed == "https://www.w3.org/ns/activitystreams#Public" ||
-				narrowed == "as:Public" || narrowed == "Public" { continue }
-			url, err := url.Parse(narrowed)
-			if err != nil { continue }
-			object, err := FetchURL(url)
-			if err != nil { continue }
-			asT, isT := object.(T)
-			if !isT { continue }
-			output = append(output, asT)
-		default: continue
-		}
-	}
-
-	return output, nil
-}
-
-func GetItem[T Content](d Dict, key string) (T, error) {
-	value, err := Get[any](d, key)
-	if err != nil {
-		var dummy T
-		return dummy, err
-	}
-
-	source, _ := GetURL(d, "id")
-
-	fetched, err := FetchUnknown(value, source)
-
-	asT, isT := fetched.(T)
-
-	if !isT {
-		errors.New("Fetched " + key + " on " + source.String() + " is not of the desired type")
-	}
-
-	return asT, nil
-}
-
-/*
-	`GetList`
-	For a given key, return the value if it is a
-	slice, if not, put it in a slice and return that.
-*/
-func GetList(d Dict, key string) ([]any, error) {
-	value, err := Get[any](d, key)
-	if err != nil { return []any{}, err }
-	if asList, isList := value.([]any); isList {
-		return asList, nil
-	} else {
-		return []any{value}, nil
-	}
-}
-
-/*
-	`GetLinksStrict`
-	Returns a list
-	of Links. Strings are interpreted as Links and
-	are not fetched. If d.content is absent, d.mediaType
-	is interpreted as applying to these strings.
-	Non-string, non-Link elements are ignored.
-
-	Used for `Post.url`.
-*/
-// TODO: for simplicity, make this a method of Post,
-// it is easier to conceptualize when it works only on
-// Posts, plus I can use my other post methods
-func GetLinksStrict(d Dict, key string) ([]Link, error) {
-	values, err := GetList(d, key)
-	if err != nil {
-		return nil, err
-	}
-	
-	output := []Link{}
-
-	// if content is absent and mediaType is present,
-	// mediaType applies to the Links
-	// name applies to the Links
-	// nil/null represents absence
-	var defaultMediaType any // (string | nil)
-	mediaType, mediaTypeErr := Get[string](d, "mediaType")
-	_, contentErr := GetNatural(d, "content", "en")
-	if mediaTypeErr != nil || contentErr == nil {
-		defaultMediaType = nil
-	} else { defaultMediaType = mediaType }
-	var defaultName any // (string | nil)
-	if name, nameErr := GetNatural(d, "name", "en"); nameErr != nil {
-		defaultName = nil
-	} else { defaultName = name }
-
-	for _, el := range values {
-		switch narrowed := el.(type) {
-		case string:
-			output = append(output, Link{
-				"type": "Link",
-				"href": narrowed,
-				"name": defaultName,
-				"mediaType": defaultMediaType,
-			})
-		case Dict:
-			// TODO: need to check this error?
-			source, err := GetURL(d, "id")
-			constructed, err := Construct(narrowed, source)
-			if err != nil { continue }
-			switch narrowedConstructed := constructed.(type) {
-			case Link:
-				output = append(output, narrowedConstructed)
-			// TODO: ignore this case
-			case Post:
-				if postLink, err := narrowedConstructed.Link(); err != nil {
-					output = append(output, postLink)
-				} else { continue }
-			default: continue
-			}
-		default: continue
-		}
-	}
-
-	return output, nil
-}
-
-/*
-	`GetLinksLenient`
-	Similar to `GetLinksStrict`, but converts Posts
-	to Links instead of ignoring them, and treats
-	strings as URLs (not Links) and fetches them.
-
-	Used for `Post.attachment`, `Actor.icon`, etc.
-*/
-func GetLinksLenient(d Dict, key string) ([]Link, error) {
-	values, err := GetContent[Content](d, key)
-	if err != nil {
-		return []Link{}, err
-	}
-
-	output := []Link{}
-
-	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
-}

+ 8 - 0
kinds/item.go

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

+ 20 - 33
kinds/link.go

@@ -2,60 +2,49 @@ package kinds
 
 import (
 	"net/url"
-	"strings"
 	"errors"
 )
 
-type Link Dict
-
-func (l Link) Raw() Dict {
-	return l
+type Link struct {
+	Object
 }
 
-func (l Link) Kind() (string, error) {
-	return "link", nil
+func (l Link) Kind() string {
+	return "link"
 }
 func (l Link) Category() string {
 	return "link"
 }
 
 func (l Link) Supertype() (string, error) {
-	mediaType, err := Get[string](l, "mediaType")
-	return strings.Split(mediaType, "/")[0], err
+	mediaType, err := l.GetMediaType("mediaType")
+	if err != nil { return "", err }
+	return mediaType.Supertype, nil
 }
 
 func (l Link) Subtype() (string, error) {
-	if mediaType, err := Get[string](l, "mediaType"); err != nil {
-		return "", err
-	} else if split := strings.Split(mediaType, "/"); len(split) < 2 {
-		return "", errors.New("Media type " + mediaType + " lacks a subtype")
-	} else {
-		return split[1], nil
-	}
+	mediaType, err := l.GetMediaType("mediaType")
+	if err != nil { return "", err }
+	return mediaType.Subtype, nil
 }
 
 func (l Link) URL() (*url.URL, error) {
-	return GetURL(l, "href")
+	return l.GetURL("href")
 }
 
 func (l Link) Alt() (string, error) {
-	alt, err := Get[string](l, "name")
+	alt, err := l.GetString("name")
 	if alt == "" || err != nil {
-		alt, err = Get[string](l, "href")
+		alt, err = l.GetString("href")
+		if err != nil { return "", err }
 	}
-	return strings.TrimSpace(alt), err
-}
-
-func (l Link) Identifier() (*url.URL, error) {
-	return nil, nil
+	return alt, nil
 }
 
-// used for link prioritization, roughly
-// related to resolution
-func (l Link) rating() float64 {
-	height, err := Get[float64](l, "height")
+func (l Link) rating() uint64 {
+	height, err := l.GetNumber("height")
 	if err != nil { height = 1 }
-	width, err := Get[float64](l, "width")
+	width, err := l.GetNumber("width")
 	if err != nil { width = 1 }
 	return height * width
 }
@@ -80,11 +69,9 @@ func (l Link) Preview() (string, error) {
 	return "todo", nil
 }
 
-// TODO: must test when list only has 1 link (probably works)
-// TODO: pass in *MediaType instead of supertype
 func SelectBestLink(links []Link, supertype string) (Link, error) {
 	if len(links) == 0 {
-		return nil, 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]
@@ -120,7 +107,7 @@ func SelectBestLink(links []Link, supertype string) (Link, error) {
 
 func SelectFirstLink(links []Link) (Link, error) {
 	if len(links) == 0 {
-		return nil, 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
 	}

+ 187 - 0
kinds/object.go

@@ -0,0 +1,187 @@
+package kinds
+
+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, width)
+}

+ 53 - 52
kinds/post.go

@@ -5,53 +5,39 @@ import (
 	"strings"
 	"time"
 	"mimicry/style"
-	"mimicry/render"
 	"mimicry/ansi"
 )
 
-type Post Dict
-
-// TODO: go through and remove all the trims, they
-// make things less predictable
-// TODO: make the Post references *Post because why not
-
-func (p Post) Raw() Dict {
-	return p
+type Post struct {
+	Object
 }
 
-func (p Post) Kind() (string, error) {
-	kind, err := Get[string](p, "type")
-	return strings.ToLower(kind), err
+func (p Post) Kind() (string) {
+	kind, err := p.GetString("type")
+	if err != nil {
+		panic(err)
+	}
+	return strings.ToLower(kind)
 }
 
 func (p Post) Title() (string, error) {
-	title, err := GetNatural(p, "name", "en")
-	return strings.TrimSpace(title), err
+	return p.GetNatural("name", "en")
 }
 
 func (p Post) Body(width int) (string, error) {
-	body, err := GetNatural(p, "content", "en")
-	if err != nil {
-		return "", err
-	}
-	mediaType, err := Get[string](p, "mediaType")
-	if err != nil {
-		mediaType = "text/html"
-	}
-	return render.Render(body, mediaType, width)
+	return p.Render("content", "en", "mediaType", width)
 }
 
 func (p Post) Identifier() (*url.URL, error) {
-	return GetURL(p, "id")
+	return p.GetURL("id")
 }
 
 func (p Post) Created() (time.Time, error) {
-	return GetTime(p, "published")
+	return p.GetTime("published")
 }
 
-// TODO: rename to edited
-func (p Post) Updated() (time.Time, error) {
-	return GetTime(p, "updated")
+func (p Post) Edited() (time.Time, error) {
+	return p.GetTime("updated")
 }
 
 func (p Post) Category() string {
@@ -59,44 +45,63 @@ func (p Post) Category() string {
 }
 
 func (p Post) Creators() ([]Actor, error) {
-	return GetContent[Actor](p, "attributedTo")
+	return p.GetActors("attributedTo")
 }
 
 func (p Post) Recipients() ([]Actor, error) {
-	return GetContent[Actor](p, "to")
+	return p.GetActors("to")
 }
 
 func (p Post) Attachments() ([]Link, error) {
-	return GetLinksLenient(p, "attachment")
+	return p.GetLinks("attachment")
 }
 
 func (p Post) Comments() (Collection, error) {
-	replies, repliesErr := GetItem[Collection](p, "replies")
-	if repliesErr != nil {
-		comments, commentsErr := GetItem[Collection](p, "comments")
-		if commentsErr != nil {
-			return Collection{}, repliesErr
-		}
-		replies = comments
+	if p.Has("comments") && !p.Has("replies") {
+		return p.GetCollection("comments")
 	}
-	return replies, nil
+	return p.GetCollection("replies")
 }
 
 func (p Post) Link() (Link, error) {
-	kind, err := p.Kind()
+	values, err := p.GetList("url")
 	if err != nil {
-		return nil, err
-	}
-
-	links, err := GetLinksStrict(p, "url")
-	if err != nil {
-		return nil, err
+		return Link{}, err
+	}
+	
+	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)
+			}
+		}
 	}
 
+	kind := p.Kind()
 	switch kind {
 	case "audio", "image", "video":
 		return SelectBestLink(links, kind)
-	default: // "article", "document", "note", "page":
+	default:
 		return SelectFirstLink(links)
 	}
 }
@@ -108,9 +113,7 @@ func (p Post) header(width int) (string, error) {
 		output += style.Bold(title) + "\n"
 	}
 
-	if kind, err := p.Kind(); err == nil {
-		output += style.Color(kind)
-	}
+	output += style.Color(p.Kind())
 
 	if creators, err := p.Creators(); err == nil {
 		names := []string{}
@@ -207,7 +210,5 @@ func (p Post) Preview() (string, error) {
 		output += "\n"
 	}
 
-	// TODO: there should probably be attachments here
-
 	return output, nil
 }

+ 5 - 7
main.go

@@ -1,12 +1,10 @@
 package main
 
 import (
-	"encoding/json"
+	"fmt"
 	"mimicry/kinds"
 	"os"
-	"fmt"
-	// "mimicry/style"
-	// "mimicry/render"
+	"encoding/json"
 )
 
 // TODO: when returning errors, use zero value for return
@@ -20,14 +18,14 @@ func main() {
 	link := os.Args[len(os.Args)-1]
 	command := os.Args[1]
 
-	content, err := kinds.FetchUserInput(link)
+	item, err := kinds.FetchUserInput(link)
 	if err != nil {
 		panic(err)
 	}
 
 	if command == "raw" {
 		enc := json.NewEncoder(os.Stdout)
-		if err := enc.Encode(content.Raw()); err != nil {
+		if err := enc.Encode(item); err != nil {
 			panic(err)
 		}
 		return
@@ -42,7 +40,7 @@ func main() {
 	// 	return
 	// }
 
-	if str, err := content.String(90); err != nil {
+	if str, err := item.String(90); err != nil {
 		panic(err)
 	} else {
 		fmt.Print(str)

+ 0 - 35
preamble/preamble.go

@@ -1,35 +0,0 @@
-package preamble
-
-import (
-	"errors"
-)
-
-type Result[T any] struct {
-	Ok T
-	Err error
-}
-
-func Ok[T any](value T) *Result[T] {
-	return &Result[T] {
-		Ok: value,
-		Err: nil,
-	}
-}
-
-func Err[T any](errs ...error) *Result[T] {
-	return &Result[T] {
-		Ok: *new(T),
-		/* this drops nil arguments automatically */
-		Err: errors.Join(errs...),
-	}
-}
-
-func AwaitAll[T any](channels ...<-chan T) []T {
-	output := make([]T, len(channels))
-
-	for i, channel := range channels {
-		output[i] = <-channel
-	}
-
-	return output
-}