post.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. package pub
  2. import (
  3. "errors"
  4. "fmt"
  5. "golang.org/x/exp/slices"
  6. "servitor/ansi"
  7. "servitor/client"
  8. "servitor/mime"
  9. "servitor/object"
  10. "servitor/style"
  11. "net/url"
  12. "strings"
  13. "sync"
  14. "time"
  15. )
  16. type Post struct {
  17. kind string
  18. id *url.URL
  19. title string
  20. titleErr error
  21. body object.Markup
  22. bodyLinks []string
  23. bodyErr error
  24. media *Link
  25. mediaErr error
  26. created time.Time
  27. createdErr error
  28. edited time.Time
  29. editedErr error
  30. parentObject object.Object
  31. parentIdentifier *url.URL
  32. parentErr error
  33. // just as body dies completely if members die,
  34. // attachments dies completely if any member dies
  35. attachments []*Link
  36. attachmentsErr error
  37. creators []Tangible
  38. recipients []Tangible
  39. comments *Collection
  40. commentsErr error
  41. }
  42. func NewPost(input any, source *url.URL) (*Post, error) {
  43. o, id, err := client.FetchUnknown(input, source)
  44. if err != nil {
  45. return nil, err
  46. }
  47. return NewPostFromObject(o, id)
  48. }
  49. func NewPostFromObject(o object.Object, id *url.URL) (*Post, error) {
  50. p := &Post{}
  51. p.id = id
  52. var err error
  53. if p.kind, err = o.GetString("type"); err != nil {
  54. return nil, err
  55. }
  56. if p.kind == "Tombstone" {
  57. return nil, errors.New("post was deleted")
  58. }
  59. if !slices.Contains([]string{
  60. "Article", "Audio", "Document", "Image", "Note", "Page", "Video",
  61. }, p.kind) {
  62. return nil, fmt.Errorf("%w: %s is not a Post", ErrWrongType, p.kind)
  63. }
  64. p.title, p.titleErr = o.GetString("name")
  65. p.body, p.bodyLinks, p.bodyErr = o.GetMarkup("content", "mediaType")
  66. p.created, p.createdErr = o.GetTime("published")
  67. p.edited, p.editedErr = o.GetTime("updated")
  68. p.parentObject, p.parentIdentifier, p.parentErr = getAndFetchUnkown(o, "inReplyTo", p.id)
  69. if p.kind == "Audio" || p.kind == "Video" || p.kind == "Image" {
  70. p.media, p.mediaErr = getBestLinkShorthand(o, "url", strings.ToLower(p.kind))
  71. } else {
  72. p.media, p.mediaErr = getFirstLinkShorthand(o, "url")
  73. }
  74. var wg sync.WaitGroup
  75. wg.Add(4)
  76. go func() { p.creators = getActors(o, "attributedTo", p.id); wg.Done() }()
  77. go func() { p.recipients = getActors(o, "audience", p.id); wg.Done() }()
  78. go func() { p.attachments, p.attachmentsErr = getLinks(o, "attachment"); wg.Done() }()
  79. constructComment := func(input any, source *url.URL) Tangible {
  80. comment, err := NewPost(input, source)
  81. if err != nil {
  82. return NewFailure(err)
  83. }
  84. if id == nil {
  85. return NewFailure(errors.New("comment does not reference this parent (parent lacks an identifier)"))
  86. }
  87. if comment.ParentIdentifier() == nil || comment.ParentIdentifier().String() != id.String() {
  88. return NewFailure(errors.New("comment does not reference this parent"))
  89. }
  90. return comment
  91. }
  92. go func() {
  93. p.comments, p.commentsErr = getCollection(o, "replies", p.id, constructComment)
  94. if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
  95. p.comments, p.commentsErr = getCollection(o, "comments", p.id, constructComment)
  96. }
  97. wg.Done()
  98. }()
  99. wg.Wait()
  100. /* Ensure that creators come from the same host as the post itself */
  101. for _, creator := range p.creators {
  102. if asActor, isActor := creator.(*Actor); isActor {
  103. if asActor.Identifier() == nil && id == nil {
  104. continue
  105. }
  106. if (asActor.Identifier() == nil || id == nil) || asActor.Identifier().Host != id.Host {
  107. return nil, errors.New("post contains forged creators")
  108. }
  109. }
  110. /* These are necessarily Failure types, so don't need to be checked */
  111. }
  112. return p, nil
  113. }
  114. func (p *Post) Children() Container {
  115. /* the if is necessary because my understanding is
  116. the first nil is a (*Collection)(nil) whereas
  117. the second is (Container)(nil) */
  118. if p.comments == nil {
  119. return nil
  120. } else {
  121. return p.comments
  122. }
  123. }
  124. func (p *Post) Parents(quantity uint) ([]Tangible, Tangible) {
  125. if quantity == 0 {
  126. if errors.Is(p.parentErr, object.ErrKeyNotPresent) {
  127. return []Tangible{}, nil
  128. }
  129. return []Tangible{}, p
  130. }
  131. if errors.Is(p.parentErr, object.ErrKeyNotPresent) {
  132. return []Tangible{}, nil
  133. }
  134. if p.parentErr != nil {
  135. return []Tangible{NewFailure(p.parentErr)}, nil
  136. }
  137. parent, err := NewPostFromObject(p.parentObject, p.parentIdentifier)
  138. if err != nil {
  139. return []Tangible{NewFailure(err)}, nil
  140. }
  141. if quantity == 1 {
  142. return []Tangible{parent}, parent
  143. }
  144. parentParents, parentFrontier := parent.Parents(quantity - 1)
  145. return append([]Tangible{parent}, parentParents...), parentFrontier
  146. }
  147. func (p *Post) ParentIdentifier() *url.URL {
  148. if p.parentErr != nil {
  149. return nil
  150. }
  151. return p.parentIdentifier
  152. }
  153. func (p *Post) header(width int) string {
  154. output := ""
  155. if p.titleErr == nil {
  156. output += style.Bold(p.title) + "\n"
  157. } else if !errors.Is(p.titleErr, object.ErrKeyNotPresent) {
  158. output += style.Problem(fmt.Errorf("failed to get title: %w", p.titleErr)) + "\n"
  159. }
  160. if errors.Is(p.parentErr, object.ErrKeyNotPresent) {
  161. output += style.Color(strings.ToLower(p.kind))
  162. } else {
  163. output += style.Color("comment")
  164. }
  165. /* TODO: forgery checking is needed here; verify that the id of the post
  166. and id of the creators match */
  167. if len(p.creators) > 0 {
  168. output += " by "
  169. for i, creator := range p.creators {
  170. output += style.Color(creator.Name())
  171. if i != len(p.creators)-1 {
  172. output += ", "
  173. }
  174. }
  175. }
  176. if len(p.recipients) > 0 {
  177. output += " to "
  178. for i, recipient := range p.recipients {
  179. output += style.Color(recipient.Name())
  180. if i != len(p.recipients)-1 {
  181. output += ", "
  182. }
  183. }
  184. }
  185. if p.createdErr != nil && !errors.Is(p.createdErr, object.ErrKeyNotPresent) {
  186. output += " at " + style.Problem(p.createdErr)
  187. } else {
  188. output += " • " + style.Color(ago(p.created))
  189. }
  190. return ansi.Wrap(output, width)
  191. }
  192. func (p *Post) center(width int) (string, bool) {
  193. if errors.Is(p.bodyErr, object.ErrKeyNotPresent) {
  194. return "", false
  195. }
  196. if p.bodyErr != nil {
  197. return ansi.Wrap(style.Problem(p.bodyErr), width), true
  198. }
  199. rendered := p.body.Render(width)
  200. return rendered, true
  201. }
  202. func (p *Post) supplement(width int) (string, bool) {
  203. if errors.Is(p.attachmentsErr, object.ErrKeyNotPresent) {
  204. return "", false
  205. }
  206. if p.attachmentsErr != nil {
  207. return ansi.Wrap(style.Problem(fmt.Errorf("failed to load attachments: %w", p.attachmentsErr)), width), true
  208. }
  209. if len(p.attachments) == 0 {
  210. return "", false
  211. }
  212. // TODO: don't think this is good, rework it
  213. output := ""
  214. for i, attachment := range p.attachments {
  215. if output != "" {
  216. output += "\n"
  217. }
  218. alt, err := attachment.Alt()
  219. if err != nil {
  220. output += style.Problem(err)
  221. continue
  222. }
  223. output += style.LinkBlock(ansi.Wrap(alt, width-2), len(p.bodyLinks)+i+1)
  224. }
  225. return output, true
  226. }
  227. func (p *Post) footer(width int) string {
  228. if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
  229. return style.Color("comments disabled")
  230. } else if p.commentsErr != nil {
  231. return style.Color("comments enabled")
  232. } else if quantity, err := p.comments.Size(); errors.Is(err, object.ErrKeyNotPresent) {
  233. return style.Color("comments enabled")
  234. } else if err != nil {
  235. return style.Problem(err)
  236. } else if quantity == 1 {
  237. return style.Color(fmt.Sprintf("%d comment", quantity))
  238. } else {
  239. return style.Color(fmt.Sprintf("%d comments", quantity))
  240. }
  241. }
  242. func (p Post) String(width int) string {
  243. output := p.header(width)
  244. if body, present := p.center(width - 4); present {
  245. output += "\n\n" + ansi.Indent(body, " ", true)
  246. }
  247. if attachments, present := p.supplement(width - 4); present {
  248. output += "\n\n" + ansi.Indent(attachments, " ", true)
  249. }
  250. output += "\n\n" + p.footer(width)
  251. return output
  252. }
  253. func (p *Post) Preview(width int) string {
  254. output := p.header(width)
  255. body, bodyPresent := p.center(width)
  256. if bodyPresent {
  257. output += "\n" + body
  258. }
  259. if attachments, present := p.supplement(width); present {
  260. if bodyPresent {
  261. output += "\n"
  262. }
  263. output += "\n" + attachments
  264. }
  265. return ansi.Snip(output, width, 4, style.Color("\u2026"))
  266. }
  267. func (p *Post) Timestamp() time.Time {
  268. if p.createdErr != nil {
  269. return time.Time{}
  270. } else {
  271. return p.created
  272. }
  273. }
  274. func (p *Post) Name() string {
  275. if p.titleErr != nil {
  276. return style.Problem(p.titleErr)
  277. }
  278. return p.title
  279. }
  280. func (p *Post) Creators() []Tangible {
  281. return p.creators
  282. }
  283. func (p *Post) Recipients() []Tangible {
  284. return p.recipients
  285. }
  286. func (p *Post) Media() (string, *mime.MediaType, bool) {
  287. if p.mediaErr != nil {
  288. return "", nil, false
  289. }
  290. if p.kind == "Audio" || p.kind == "Video" || p.kind == "Image" {
  291. return p.media.SelectWithDefaultMediaType(mime.UnknownSubtype(strings.ToLower(p.kind)))
  292. }
  293. return p.media.Select()
  294. }
  295. func (p *Post) SelectLink(input int) (string, *mime.MediaType, bool) {
  296. input -= 1
  297. if len(p.bodyLinks) > input {
  298. return p.bodyLinks[input], mime.Unknown(), true
  299. }
  300. nextIndex := input - len(p.bodyLinks)
  301. if len(p.attachments) > nextIndex {
  302. return p.attachments[nextIndex].Select()
  303. }
  304. return "", nil, false
  305. }