Browse Source

post and actor each partially done--overall concept is working

Benton Edmondson 2 years ago
commit
13d8e7575b
11 changed files with 541 additions and 0 deletions
  1. 1 0
      .gitignore
  2. 3 0
      go.mod
  3. 66 0
      kinds/actor.go
  4. 121 0
      kinds/create.go
  5. 126 0
      kinds/post.go
  6. 58 0
      main.go
  7. 9 0
      render/html.go
  8. 20 0
      render/render.go
  9. 60 0
      request/request.go
  10. 64 0
      shared/extractor.go
  11. 13 0
      style/style.go

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+mi

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module mimicry
+
+go 1.19

+ 66 - 0
kinds/actor.go

@@ -0,0 +1,66 @@
+package kinds
+
+import (
+	"strings"
+	"net/url"
+	"mimicry/shared"
+	"mimicry/style"
+	"fmt"
+)
+
+type Actor map[string]any
+
+func (a Actor) Kind() (string, error) {
+	kind, err := shared.Get[string](a, "type")
+	return strings.ToLower(kind), err
+}
+
+func (a Actor) Name() (string, error) {
+	name, err := shared.GetNatural(a, "name", "en")
+	return strings.TrimSpace(name), err
+}
+
+func (a Actor) InlineName() (string, error) {
+	name, err := a.Name()
+	if err != nil {
+		return "", err
+	}
+	id, err := a.Identifier()
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("%s (%s)", name, id.Hostname()), nil
+}
+
+func (a Actor) Category() string {
+	return "actor"
+}
+
+func (a Actor) Identifier() (*url.URL, error) {
+	return shared.GetURL(a, "id")
+}
+
+func (a Actor) Bio() (string, error) {
+	bio, err := shared.GetNatural(a, "summary", "en")
+	return strings.TrimSpace(bio), err
+}
+
+func (a Actor) String() string {
+	output := ""
+
+	name, err := a.InlineName()
+	if err == nil {
+		output += style.Bold(name)
+	}
+	kind, err := a.Kind()
+	if err == nil {
+		output += " "
+		output += kind
+	}
+	bio, err := a.Bio()
+	if err == nil {
+		output += "\n"
+		output += bio
+	}
+	return output
+}

+ 121 - 0
kinds/create.go

@@ -0,0 +1,121 @@
+package kinds
+
+// TODO: rename this to `construct`
+// TODO: I think this should be moved to
+// package request, then Fetch will return an Object
+// directly by calling Create, and Create will
+// still work fine
+
+import (
+	"errors"
+	"net/url"
+	"mimicry/shared"
+	"mimicry/request"
+)
+
+type Object interface {
+	String() string
+	Kind() (string, error)
+	Identifier() (*url.URL, error)
+	Category() string
+}
+
+// TODO, add a verbose debugging output mode
+// to debug problems that arise with this thing
+// looping too much and whatnot
+
+// source is where it came from, if source is different
+// from the element's id, it will be refetched
+
+// maybe change back to taking in a unstructured shared.JSON
+func Create(input any, source *url.URL) (Object, error) {
+	unstructured, ok := input.(shared.JSON)
+	if !ok {
+		return nil, errors.New("Cannot construct with a non-object JSON")
+	}
+
+	kind, err := shared.Get[string](unstructured, "type")
+	if err != nil {
+		return nil, err
+	}
+
+	id, err := shared.GetURL(unstructured, "id")
+	if err != nil {
+		return nil, err
+	}
+
+	// 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 && source.Hostname() != id.Hostname() || len(unstructured) <= 2 {
+		response, err := request.Fetch(id)
+		if err != nil {
+			return nil, err
+		}
+		return Create(response, nil)
+	}
+
+	// TODO: if the only keys are id and type,
+	// you need to do a fetch to get the other keys
+
+	switch kind {
+	case "Article":
+		fallthrough
+	case "Audio":
+		fallthrough
+	case "Document":
+		fallthrough
+	case "Image":
+		fallthrough
+	case "Note":
+		fallthrough
+	case "Page":
+		fallthrough
+	case "Video":
+		// TODO: figure out the way to do this directly
+		post := Post{}
+		post = unstructured
+		return post, nil
+
+	// case "Create":
+	// 	fallthrough
+	// case "Announce":
+	// 	fallthrough
+	// case "Dislike":
+	// 	fallthrough
+	// case "Like":
+	// 	fallthrough
+	// case "Question":
+	// 	return Activity{unstructured}, nil
+
+	case "Application":
+		fallthrough
+	case "Group":
+		fallthrough
+	case "Organization":
+		fallthrough
+	case "Person":
+		fallthrough
+	case "Service":
+		// TODO: nicer way to do this?
+		actor := Actor{}
+		actor = unstructured
+		return actor, nil
+
+	// case "Link":
+	// 	return Link{unstructured}, nil
+
+	// case "Collection":
+	// 	fallthrough
+	// case "OrderedCollection":
+	// 	return Collection{unstructured}, nil
+
+	// case "CollectionPage":
+	// 	fallthrough
+	// case "OrderedCollectionPage":
+	// 	return CollectionPage{unstructured}, nil
+
+	default:
+		return nil, errors.New("Object of Type " + kind + " unsupported")
+	}
+}

+ 126 - 0
kinds/post.go

@@ -0,0 +1,126 @@
+package kinds
+
+import (
+	"net/url"
+	"strings"
+	"time"
+	"mimicry/shared"
+	"mimicry/style"
+	"mimicry/request"
+	"fmt"
+)
+
+type Post map[string]any
+
+// TODO: make the Post references *Post because why not
+
+func (p Post) Kind() (string, error) {
+	kind, err := shared.Get[string](p, "type")
+	return strings.ToLower(kind), err
+}
+
+func (p Post) Title() (string, error) {
+	title, err := shared.GetNatural(p, "name", "en")
+	return strings.TrimSpace(title), err
+}
+
+func (p Post) Body() (string, error) {
+	body, err := shared.GetNatural(p, "content", "en")
+	return strings.TrimSpace(body), err
+}
+
+func (p Post) BodyPreview() (string, error) {
+	body, err := p.Body()
+	return fmt.Sprintf("%s…", string([]rune(body)[:280])), err
+}
+
+func (p Post) Identifier() (*url.URL, error) {
+	return shared.GetURL(p, "id")
+}
+
+func (p Post) Created() (time.Time, error) {
+	return shared.GetTime(p, "published")
+}
+
+func (p Post) Updated() (time.Time, error) {
+	return shared.GetTime(p, "updated")
+}
+
+func (p Post) Category() string {
+	return "post"
+}
+
+func (p Post) Creators() []Actor {
+	// TODO: this line needs an existence check
+	attributedTo, ok := p["attributedTo"]
+	if !ok {
+		return []Actor{}
+	}
+
+	// if not an array, make it an array
+	attributions := []any{}
+	if attributedToList, isList := attributedTo.([]any); isList {
+		attributions = attributedToList
+	} else {
+		attributions = []any{attributedTo}
+	}
+
+	output := []Actor{}
+
+	for _, el := range attributions {
+		switch narrowed := el.(type) {
+		case shared.JSON:
+			source, err := p.Identifier()
+			if err != nil { continue }
+			resolved, err := Create(narrowed, source)
+			if err != nil { continue }
+			actor, isActor := resolved.(Actor)
+			if !isActor { continue }
+			output = append(output, actor)
+		case string:
+			url, err := url.Parse(narrowed)
+			if err != nil { continue }
+			response, err := request.Fetch(url)
+			if err != nil { continue }
+			// this step will be implicit after merge
+			structured, err := Create(response, url)
+			if err != nil { continue }
+			actor, isActor := structured.(Actor)
+			if !isActor { continue }
+			output = append(output, actor)
+		default: continue
+		}
+	}
+
+	return output
+}
+
+func (p Post) String() string {
+	output := ""
+
+	if title, err := p.Title(); err == nil {
+		output += style.Bold(title)
+		output += "\n"
+	}
+
+
+	if body, err := p.BodyPreview(); err == nil {
+		output += body
+		output += "\n"
+	}
+
+	if created, err := p.Created(); err == nil {
+		output += time.Now().Sub(created).String()
+	}
+
+	if creators := p.Creators(); len(creators) != 0 {
+		output += " "
+		for _, creator := range creators {
+			if name, err := creator.InlineName(); err == nil {
+				output += style.Bold(name) + ", "
+			}
+		}
+	}
+
+	return strings.TrimSpace(output)
+}

+ 58 - 0
main.go

@@ -0,0 +1,58 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"mimicry/kinds"
+	"mimicry/request"
+	"net/url"
+	"os"
+)
+
+func main() {
+	// I need to figure out the higher level abstractions
+	// a package with a function that takes a url as string
+	// and returns an Activity, Actor, Collection, or Post
+	// really it will return a Thing interface, which implements
+	// Kind, Identifier, String
+
+	// the request function will need to disable bs like cookies,
+	// etc, enable caching, set Accept header, check the header and
+	// status code after receiving the request, parse the json with
+	// strict validation, look at type to determine what to construct,
+	// then return it
+
+	// Other types I need to make are Link and Markup
+
+	// TODO: maybe make a package called onboard that combines
+	// request, extractor, and create
+	// onboard.Fetch, onboard.Construct, onboard.Get, etc
+
+	link := os.Args[len(os.Args)-1]
+	command := os.Args[1]
+
+	url, err := url.Parse(link)
+	if err != nil {
+		panic(err)
+	}
+
+	unstructured, err := request.Fetch(url)
+	if err != nil {
+		panic(err)
+	}
+
+	if command == "raw" {
+		enc := json.NewEncoder(os.Stdout)
+		if err := enc.Encode(unstructured); err != nil {
+			panic(err)
+		}
+		return
+	}
+
+	object, err := kinds.Create(unstructured, url)
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Println(object.String())
+}

+ 9 - 0
render/html.go

@@ -0,0 +1,9 @@
+package render
+
+import (
+	"net/html"
+)
+
+func renderHTML(node *html.Node) (string, error) {
+	
+}

+ 20 - 0
render/render.go

@@ -0,0 +1,20 @@
+package render
+
+import (
+	"strings"
+	"errors"
+)
+
+func Render(text string, kind string) (string, error) {
+	switch {
+	case strings.Contains(kind, "text/plain"): 
+		return text, nil
+	case strings.Contains(kind, "text/html"):
+		node, err := html.Parse(text)
+		if err == nil {
+			return "", err
+		}
+		return renderHTML(node), nil
+	default:
+		return "", errors.New("Cannot render text of mime type %s", kind)
+}

+ 60 - 0
request/request.go

@@ -0,0 +1,60 @@
+package request
+
+import (
+	"strings"
+	"net/http"
+	"net/url"
+	"errors"
+	"io/ioutil"
+	"encoding/json"
+	"fmt"
+)
+
+var client = &http.Client{}
+//var cache = TODO
+
+func Fetch(link *url.URL) (map[string]any, error) {
+	const requiredContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
+	const optionalContentType = "application/activity+json"
+
+	// convert URL to string
+	url := link.String()
+
+	// create the get request
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return map[string]any{}, err
+	}
+
+	// add the accept header
+	// § 3.2
+	req.Header.Add("Accept", fmt.Sprintf("%s, %s", requiredContentType, optionalContentType))
+
+	// send the request
+	resp, err := client.Do(req)
+	if err != nil {
+		return map[string]any{}, err
+	}
+
+	// check the status code
+	if resp.StatusCode != 200 {
+		return nil, errors.New("The server returned a status code of " + string(resp.StatusCode))
+	}
+
+	// check the response content type
+	if contentType := resp.Header.Get("Content-Type"); contentType == "" {
+		return nil, errors.New("The server's response did not contain a content type")
+	} else if !strings.Contains(contentType, requiredContentType) && !strings.Contains(contentType, optionalContentType) {
+		return nil, errors.New("The server responded with the invalid content type of " + contentType)
+	}
+
+	// read the body into a map
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(resp.Body)
+	var object map[string]any
+	if err := json.Unmarshal(body, &object); err != nil {
+		return nil, err
+	}
+
+	return object, nil
+}

+ 64 - 0
shared/extractor.go

@@ -0,0 +1,64 @@
+package shared
+
+import (
+	"errors"
+	"net/url"
+	"time"
+)
+
+// TODO throughout this file: attach the problematic object to the error
+
+type JSON = map[string]any
+
+func Get[T any](o JSON, 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 value, ok := value.(T); !ok {
+		return zero, errors.New("Key " + key + " is not of the desired type")
+	} else {
+		return value, nil
+	}
+}
+
+// some fields have "natural language values" meaning that I should check
+// `contentMap[language]`, followed by `content`, followed by `contentMap["und"]`
+// to find the content of the post
+// https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues
+func GetNatural(o JSON, key string, language string) (string, error) {
+	values, valuesErr := Get[JSON](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 JSON, 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 JSON, key string) (*url.URL, error) {
+	if value, err := Get[string](o, key); err != nil {
+		return nil, err
+	} else {
+		return url.Parse(value)
+	}
+}

+ 13 - 0
style/style.go

@@ -0,0 +1,13 @@
+package style
+
+import (
+	"fmt"
+)
+
+func Display(text string, code int) string {
+	return fmt.Sprintf("\x1b[%dm%s\x1b[0m", code, text)
+}
+
+func Bold(text string) string {
+	return Display(text, 1)
+}