Browse Source

Develop (#621)

Merge of #618 and #619 for testing

Co-authored-by: James Mills <prologic@shortcircuit.net.au>
Reviewed-on: #621
Co-authored-by: James Mills <james@mills.io>
Co-committed-by: James Mills <james@mills.io>
pull/622/head
James Mills 4 weeks ago
parent
commit
8c7badb220
  1. 2
      client/client.go
  2. 10
      cmd/yarnc/stats.go
  3. 5
      cmd/yarnc/ui.go
  4. 8
      internal/api.go
  5. 163
      internal/cache.go
  6. 2
      internal/context.go
  7. 16
      internal/conversation_handler.go
  8. 8
      internal/external_handlers.go
  9. 6
      internal/handlers.go
  10. 14
      internal/models.go
  11. 14
      internal/permalink_handler.go
  12. 30
      internal/theme/templates/partials.html
  13. 7
      internal/twt.go
  14. 26
      internal/utils.go
  15. 2
      internal/utils_test.go
  16. 25
      types/lextwt/ast.go
  17. 28
      types/lextwt/lextwt.go
  18. 35
      types/lextwt/lextwt_test.go
  19. 65
      types/twt.go
  20. 26
      types/twt_test.go

2
client/client.go

@ -149,7 +149,7 @@ func (c *Client) GetAndSetTwter() error {
log.WithError(err).Error("error retrieving user profile")
return err
}
c.Twter = types.Twter{Nick: "me", URL: res.Profile.URL}
c.Twter = types.Twter{Nick: "me", URI: res.Profile.URL}
return nil
}

10
cmd/yarnc/stats.go

@ -72,7 +72,8 @@ func runStats(args []string) {
}
func doStats(r io.Reader) {
tf, err := lextwt.ParseFile(r, types.NilTwt.Twter())
twter := types.NilTwt.Twter()
tf, err := lextwt.ParseFile(r, &twter)
if err != nil {
log.WithError(err).Error("error parsing feed")
os.Exit(2)
@ -80,10 +81,9 @@ func doStats(r io.Reader) {
fmt.Println(tf.Info())
twter := tf.Twter()
fmt.Printf("twter: %s\n", twter.DomainNick())
fmt.Printf("nick: %s\n", twter.Nick)
fmt.Printf("url: %s\n", twter.URL)
fmt.Printf("url: %s\n", twter.URI)
fmt.Printf("avatar: %s\n", twter.Avatar)
fmt.Printf("tagline: %s\n", twter.Tagline)
@ -94,7 +94,7 @@ func doStats(r io.Reader) {
fmt.Println("following:")
for _, c := range tf.Info().Following() {
fmt.Printf(" % -30s = %s\n", c.Nick, c.URL)
fmt.Printf(" % -30s = %s\n", c.Nick, c.URI)
}
fmt.Println("twts: ", len(tf.Twts()))
@ -143,7 +143,7 @@ func getMentions(twts types.Twts, follows []types.Twter) stats {
counts := make(map[string]int)
for _, m := range twts.Mentions() {
t := m.Twter()
counts[fmt.Sprint(t.Nick, "\t", t.URL)]++
counts[fmt.Sprint(t.Nick, "\t", t.URI)]++
}
lis := make(stats, 0, len(counts))

5
cmd/yarnc/ui.go

@ -9,9 +9,12 @@ import (
"github.com/russross/blackfriday"
)
/* red Currently unused
func red(s string) string {
return fmt.Sprintf("\033[31m%s\033[0m", s)
}
*/
func green(s string) string {
return fmt.Sprintf("\033[32m%s\033[0m", s)
}
@ -65,7 +68,7 @@ func PrintTwt(twt types.Twt, now time.Time, me types.Twter) {
func PrintTwtRaw(twt types.Twt) {
fmt.Printf(
"%s\t%s\t%t\n",
twt.Twter().URL,
twt.Twter().URI,
twt.Created().Format(time.RFC3339),
twt,
)

8
internal/api.go

@ -934,7 +934,7 @@ func (a *API) InjectEndpoint() httprouter.Handle {
GetExternalAvatar(a.config, twt.Twter())
a.cache.InjectFeed(twt.Twter().URL, twt)
a.cache.InjectFeed(twt.Twter().URI, twt)
if err := a.archive.Archive(twt); err != nil {
log.WithError(err).Warnf("error archiving injected twt %s", twt.Hash())
}
@ -991,7 +991,7 @@ func (a *API) ProfileEndpoint() httprouter.Handle {
if len(twts) > 0 {
twter = twts[0].Twter()
} else {
twter = types.Twter{Nick: profile.Username, URL: profile.URL}
twter = types.Twter{Nick: profile.Username, URI: profile.URL}
}
followers := a.cache.GetFollowers(profile)
@ -1209,7 +1209,7 @@ func (a *API) ExternalProfileEndpoint() httprouter.Handle {
if len(twts) > 0 {
twter = twts[0].Twter()
} else {
twter = types.Twter{Nick: nick, URL: uri}
twter = types.Twter{Nick: nick, URI: uri}
}
if twter.Avatar == "" {
@ -1253,7 +1253,7 @@ func (a *API) ExternalProfileEndpoint() httprouter.Handle {
following := make(map[string]string)
for followingNick, followingTwter := range twter.Follow {
following[followingNick] = followingTwter.URL
following[followingNick] = followingTwter.URI
}
profile := types.Profile{

163
internal/cache.go

@ -24,7 +24,7 @@ import (
const (
feedCacheFile = "cache"
feedCacheVersion = 19 // increase this if breaking changes occur to cache file.
feedCacheVersion = 20 // increase this if breaking changes occur to cache file.
localViewKey = "local"
discoverViewKey = "discover"
@ -42,13 +42,13 @@ func FilterOutFeedsAndBotsFactory(conf *Config) FilterFunc {
isLocal := IsLocalURLFactory(conf)
return func(twt types.Twt) bool {
twter := twt.Twter()
if strings.HasPrefix(twter.URL, "https://feeds.twtxt.net") {
if strings.HasPrefix(twter.URI, "https://feeds.twtxt.net") {
return false
}
if strings.HasPrefix(twter.URL, "https://search.twtxt.net") {
if strings.HasPrefix(twter.URI, "https://search.twtxt.net") {
return false
}
if isLocal(twter.URL) && HasString(automatedFeeds, twter.Nick) {
if isLocal(twter.URI) && HasString(automatedFeeds, twter.Nick) {
return false
}
return true
@ -58,7 +58,7 @@ func FilterOutFeedsAndBotsFactory(conf *Config) FilterFunc {
func FilterByMentionFactory(u *User) FilterFunc {
return func(twt types.Twt) bool {
for _, mention := range twt.Mentions() {
if u.Is(mention.Twter().URL) {
if u.Is(mention.Twter().URI) {
return true
}
}
@ -261,24 +261,27 @@ type Cache struct {
Views map[string]*Cached
Followers map[string]types.Followers
Twters map[string]types.Twter
Twters map[string]*types.Twter
}
func NewCache(conf *Config) *Cache {
return &Cache{
conf: conf,
conf: conf,
Version: feedCacheVersion,
Map: make(map[string]types.Twt),
Peers: make(map[string]*Peer),
Feeds: make(map[string]*Cached),
Views: make(map[string]*Cached),
Followers: make(map[string]types.Followers),
Twters: make(map[string]types.Twter),
Twters: make(map[string]*types.Twter),
}
}
// FromOldCache attempts to load an oldver version of the on-disk cache stored
// at /path/to/data/cache -- If you change the way the `*Cache` is tored on disk
// at /path/to/data/cache -- If you change the way the `*Cache` is stored on disk
// by modifying `Cache.Store()` or any of the data structures, please modfy this
// function to support loading the previous version of the on-disk cache.
func FromOldCache(conf *Config) (*Cache, error) {
@ -291,31 +294,83 @@ func FromOldCache(conf *Config) (*Cache, error) {
log.WithError(err).Error("error loading cache, cache file found but unreadable")
return nil, err
}
cache.Version = feedCacheVersion
cache.Feeds = make(map[string]*Cached)
return cache, nil
return NewCache(conf), nil
}
defer f.Close()
cleanupCorruptCache := func() (*Cache, error) {
// Remove invalid cache file.
os.Remove(fn)
cache.Version = feedCacheVersion
cache.Feeds = make(map[string]*Cached)
return cache, nil
return NewCache(conf), nil
}
dec := gob.NewDecoder(f)
err = dec.Decode(&cache)
if err != nil {
if strings.Contains(err.Error(), "wrong type") {
log.WithError(err).Error("error decoding cache. removing corrupt file.")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Version); err != nil {
log.WithError(err).Error("error decoding cache.Version, removing corrupt file")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Peers); err != nil {
log.WithError(err).Error("error decoding cache.Peers, removing corrupt file")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Feeds); err != nil {
log.WithError(err).Error("error decoding cache.Feeds, removing corrupt file")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Followers); err != nil {
log.WithError(err).Warn("error decoding cache.Followers, removing corrupt file")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Twters); err != nil {
log.WithError(err).Warn("error decoding cache.Twters, removing corrupt file")
return cleanupCorruptCache()
}
log.Infof("Loaded old Cache v%d", cache.Version)
// Migrate old Cache ...
getLiteralTextFromTwt := func(twt types.Twt) string {
var obj struct{ Text string }
data, _ := json.Marshal(twt)
json.Unmarshal(data, &obj)
return obj.Text
}
cache.Version = feedCacheVersion
for uri, twter := range cache.Twters {
if twter.URI == "" {
twter.URI = twter.URL
twter.URL = ""
}
cache.Twters[uri] = twter
}
for uri, cached := range cache.Feeds {
twts := make(types.Twts, len(cached.Twts))
for i, twt := range cached.Twts {
twter := types.Twter{
Nick: twt.Twter().Nick,
URI: twt.Twter().URL,
Avatar: twt.Twter().Avatar,
Tagline: twt.Twter().Tagline,
Following: twt.Twter().Following,
Followers: twt.Twter().Followers,
Follow: twt.Twter().Follow,
}
twts[i] = types.MakeTwt(twter, twt.Created(), getLiteralTextFromTwt(twt))
}
cache.Feeds[uri] = cached
}
cache.Refresh()
if err := cache.Store(conf); err != nil {
log.WithError(err).Errorf("error migrating old cache")
return cleanupCorruptCache()
@ -336,8 +391,7 @@ func LoadCache(conf *Config) (*Cache, error) {
log.WithError(err).Error("error loading cache, cache file found but unreadable")
return nil, err
}
cache.Version = feedCacheVersion
return cache, nil
return NewCache(conf), nil
}
defer f.Close()
@ -346,17 +400,25 @@ func LoadCache(conf *Config) (*Cache, error) {
cleanupCorruptCache := func() (*Cache, error) {
// Remove invalid cache file.
os.Remove(fn)
cache.Version = feedCacheVersion
cache.Feeds = make(map[string]*Cached)
return cache, nil
return NewCache(conf), nil
}
if err := dec.Decode(&cache.Version); err != nil {
log.WithError(err).Error("error decoding cache.Version, removing corrupt file")
return cleanupCorruptCache()
}
if cache.Version != feedCacheVersion {
log.Warnf(
"error decoding cache v%d, will try to load old cache v%d instead...",
feedCacheVersion, (feedCacheVersion - 1),
"cache.Version %d does not match %d, will try to load old cache v%d instead...",
cache.Version, feedCacheVersion, (feedCacheVersion - 1),
)
return FromOldCache(conf)
cache, err := FromOldCache(conf)
if err != nil {
log.WithError(err).Error("error loading old cache, removing corrupt file")
return cleanupCorruptCache()
}
return cache, nil
}
if err := dec.Decode(&cache.Peers); err != nil {
@ -371,21 +433,15 @@ func LoadCache(conf *Config) (*Cache, error) {
if err := dec.Decode(&cache.Followers); err != nil {
log.WithError(err).Warn("error decoding cache.Followers, removing corrupt file")
return cleanupCorruptCache()
}
if err := dec.Decode(&cache.Twters); err != nil {
log.WithError(err).Warn("error decoding cache.Twters, removing corrupt file")
return cleanupCorruptCache()
}
log.Infof("Cache version %d", cache.Version)
if cache.Version != feedCacheVersion {
log.Errorf("Cache version mismatch. Expect = %d, Got = %d. Removing old cache.", feedCacheVersion, cache.Version)
os.Remove(fn)
cache.Version = feedCacheVersion
cache.Feeds = make(map[string]*Cached)
}
cache.Refresh()
return cache, nil
}
@ -668,18 +724,21 @@ func (cache *Cache) FetchTwts(conf *Config, archive Archiver, feeds types.Feeds,
twter := cache.Twters[feed.URL]
cache.mu.RUnlock()
if twter.IsZero() {
twter = types.Twter{Nick: feed.Nick}
if twter == nil {
twter = &types.Twter{Nick: feed.Nick}
if isLocalURL(feed.URL) {
twter.URL = URLForUser(conf.BaseURL, feed.Nick)
twter.URI = URLForUser(conf.BaseURL, feed.Nick)
twter.Avatar = URLForAvatar(conf.BaseURL, feed.Nick, "")
} else {
twter.URL = feed.URL
avatar := GetExternalAvatar(conf, twter)
twter.URI = feed.URL
avatar := GetExternalAvatar(conf, *twter)
if avatar != "" {
twter.Avatar = URLForExternalAvatar(conf, feed.URL)
}
}
cache.mu.Lock()
cache.Twters[feed.URL] = twter
cache.mu.Unlock()
}
// Handle Gopher feeds
@ -700,14 +759,9 @@ func (cache *Cache) FetchTwts(conf *Config, archive Archiver, feeds types.Feeds,
twtsch <- nil
return
}
twter = tf.Twter()
if !isLocalURL(twter.Avatar) {
_ = GetExternalAvatar(conf, twter)
_ = GetExternalAvatar(conf, *twter)
}
cache.mu.Lock()
cache.Twters[feed.URL] = twter
cache.mu.Unlock()
future, twts, old := types.SplitTwts(tf.Twts(), conf.MaxCacheTTL, conf.MaxCacheItems)
if len(future) > 0 {
@ -817,14 +871,9 @@ func (cache *Cache) FetchTwts(conf *Config, archive Archiver, feeds types.Feeds,
twtsch <- nil
return
}
twter = tf.Twter()
if !isLocalURL(twter.Avatar) {
_ = GetExternalAvatar(conf, twter)
_ = GetExternalAvatar(conf, *twter)
}
cache.mu.Lock()
cache.Twters[feed.URL] = twter
cache.mu.Unlock()
future, twts, old := types.SplitTwts(tf.Twts(), conf.MaxCacheTTL, conf.MaxCacheItems)
if len(future) > 0 {
@ -910,7 +959,7 @@ func GetPeersForCached(cached *Cached, peers map[string]*Peer) Peers {
var matches Peers
for _, twt := range cached.Twts {
twterURL := NormalizeURL(twt.Twter().URL)
twterURL := NormalizeURL(twt.Twter().URI)
for uri, peer := range peers {
if strings.HasPrefix(twterURL, NormalizeURL(uri)) {
matches = append(matches, peer)
@ -972,7 +1021,7 @@ func (cache *Cache) Converge(archive Archiver) {
}
}
if missingTwt != nil {
cache.InjectFeed(missingTwt.Twter().URL, missingTwt)
cache.InjectFeed(missingTwt.Twter().URI, missingTwt)
GetExternalAvatar(cache.conf, missingTwt.Twter())
}
}
@ -1009,7 +1058,7 @@ func (cache *Cache) Refresh() {
for _, twt := range allTwts {
twtMap[twt.Hash()] = twt
if isLocalURL(twt.Twter().URL) {
if isLocalURL(twt.Twter().URI) {
localTwts = append(localTwts, twt)
}

2
internal/context.go

@ -205,7 +205,7 @@ func NewContext(s *Server, req *http.Request) *Context {
} else {
ctx.Twter = types.Twter{
Nick: user.Username,
URL: URLForUser(conf.BaseURL, user.Username),
URI: URLForUser(conf.BaseURL, user.Username),
}
ctx.User = user
ctx.IsAdmin = strings.EqualFold(username, conf.AdminUser)

16
internal/conversation_handler.go

@ -61,12 +61,12 @@ func (s *Server) ConversationHandler() httprouter.Handle {
)
twter := twt.Twter()
if isLocal(twter.URL) {
if isLocal(twter.URI) {
who = fmt.Sprintf("%s@%s", twter.Nick, s.config.LocalURL().Hostname())
image = URLForAvatar(s.config.BaseURL, twter.Nick, "")
} else {
who = fmt.Sprintf("@<%s %s>", twter.Nick, twter.URL)
image = URLForExternalAvatar(s.config, twter.URL)
who = fmt.Sprintf("@<%s %s>", twter.Nick, twter.URI)
image = URLForExternalAvatar(s.config, twter.URI)
}
when := twt.Created().Format(time.RFC3339)
@ -84,7 +84,7 @@ func (s *Server) ConversationHandler() httprouter.Handle {
ks = append(ks, tags.Tags()...)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.HasPrefix(twt.Twter().URL, s.config.BaseURL) {
if strings.HasPrefix(twt.Twter().URI, s.config.BaseURL) {
w.Header().Set(
"Link",
fmt.Sprintf(
@ -145,21 +145,21 @@ func (s *Server) ConversationHandler() httprouter.Handle {
Keywords: strings.Join(ks, ", "),
}
if strings.HasPrefix(twt.Twter().URL, s.config.BaseURL) {
if strings.HasPrefix(twt.Twter().URI, s.config.BaseURL) {
ctx.Links = append(ctx.Links, Link{
Href: fmt.Sprintf("%s/webmention", UserURL(twt.Twter().URL)),
Href: fmt.Sprintf("%s/webmention", UserURL(twt.Twter().URI)),
Rel: "webmention",
})
ctx.Alternatives = append(ctx.Alternatives, Alternatives{
Alternative{
Type: "text/plain",
Title: fmt.Sprintf("%s's Twtxt Feed", twt.Twter().Nick),
URL: twt.Twter().URL,
URL: twt.Twter().URI,
},
Alternative{
Type: "application/atom+xml",
Title: fmt.Sprintf("%s's Atom Feed", twt.Twter().Nick),
URL: fmt.Sprintf("%s/atom.xml", UserURL(twt.Twter().URL)),
URL: fmt.Sprintf("%s/atom.xml", UserURL(twt.Twter().URI)),
},
}...)
}

8
internal/external_handlers.go

@ -65,7 +65,7 @@ func (s *Server) ExternalHandler() httprouter.Handle {
if len(ctx.Twts) > 0 {
ctx.Twter = ctx.Twts[0].Twter()
} else {
ctx.Twter = types.Twter{Nick: nick, URL: uri}
ctx.Twter = types.Twter{Nick: nick, URI: uri}
}
if ctx.Twter.Avatar == "" {
@ -106,7 +106,7 @@ func (s *Server) ExternalHandler() httprouter.Handle {
following := make(map[string]string)
for followingNick, followingTwter := range ctx.Twter.Follow {
following[followingNick] = followingTwter.URL
following[followingNick] = followingTwter.URI
}
ctx.Profile = types.Profile{
@ -172,7 +172,7 @@ func (s *Server) ExternalFollowingHandler() httprouter.Handle {
if len(twts) > 0 {
ctx.Twter = twts[0].Twter()
} else {
ctx.Twter = types.Twter{Nick: nick, URL: uri}
ctx.Twter = types.Twter{Nick: nick, URI: uri}
}
if ctx.Twter.Avatar == "" {
@ -213,7 +213,7 @@ func (s *Server) ExternalFollowingHandler() httprouter.Handle {
following := make(map[string]string)
for followingNick, followingTwter := range ctx.Twter.Follow {
following[followingNick] = followingTwter.URL
following[followingNick] = followingTwter.URI
}
ctx.Profile = types.Profile{

6
internal/handlers.go

@ -416,9 +416,9 @@ func (s *Server) PostHandler() httprouter.Handle {
if _, err := s.tasks.Dispatch(NewFuncTask(func() error {
for _, m := range twt.Mentions() {
twter := m.Twter()
if !isLocalURL(twter.URL) {
if err := WebMention(twter.URL, URLForTwt(s.config.BaseURL, twt.Hash())); err != nil {
log.WithError(err).Warnf("error sending webmention to %s", twter.URL)
if !isLocalURL(twter.RequestURI) {
if err := WebMention(twter.RequestURI, URLForTwt(s.config.BaseURL, twt.Hash())); err != nil {
log.WithError(err).Warnf("error sending webmention to %s", twter.RequestURI)
}
}
}

14
internal/models.go

@ -404,7 +404,7 @@ func (u *User) FollowAndValidate(conf *Config, alias, uri string) error {
return err
}
if u.Follows(twter.URL) {
if u.Follows(twter.URI) {
return ErrAlreadyFollows
}
@ -437,7 +437,7 @@ func (u *User) FollowAndValidate(conf *Config, alias, uri string) error {
}
}
return u.Follow(alias, twter.URL)
return u.Follow(alias, twter.URI)
}
func (u *User) Follows(url string) bool {
@ -536,7 +536,7 @@ func (u *User) Profile(baseURL string, viewer *User) types.Profile {
}
func (u *User) Twter() types.Twter {
return types.Twter{Nick: u.Username, URL: u.URL}
return types.Twter{Nick: u.Username, URI: u.URL}
}
func (u *User) Filter(twts []types.Twt) (filtered []types.Twt) {
@ -547,7 +547,7 @@ func (u *User) Filter(twts []types.Twt) (filtered []types.Twt) {
filtered = make([]types.Twt, 0)
for _, twt := range twts {
if u.HasMuted(twt.Twter().URL) {
if u.HasMuted(twt.Twter().URI) {
continue
}
filtered = append(filtered, twt)
@ -561,8 +561,8 @@ func (u *User) Reply(twt types.Twt) string {
// If we follow the original twt's Twter, add them as the first mention
// only if the original twter isn't ourselves!
if u.Follows(twt.Twter().URL) && !u.Is(twt.Twter().URL) {
tokens = append(tokens, fmt.Sprintf("@%s", u.FollowsAs(twt.Twter().URL)))
if u.Follows(twt.Twter().URI) && !u.Is(twt.Twter().URI) {
tokens = append(tokens, fmt.Sprintf("@%s", u.FollowsAs(twt.Twter().URI)))
}
return fmt.Sprintf("%s ", strings.Join(tokens, " "))
@ -574,7 +574,7 @@ func (u *User) Fork(twt types.Twt) string {
// If we follow the original twt's Twter, add them as the first mention
// only if the original twter isn't ourselves!
if u.Follows(twt.Twter().URL) && !u.Is(twt.Twter().URL) {
if u.Follows(twt.Twter().URI) && !u.Is(twt.Twter().URI) {
tokens = append(tokens, fmt.Sprintf("@%s", twt.Twter().Nick))
}

14
internal/permalink_handler.go

@ -81,10 +81,10 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
who := twt.Twter().DomainNick()
var image string
if isLocal(twt.Twter().URL) {
if isLocal(twt.Twter().URI) {
image = URLForAvatar(s.config.BaseURL, twt.Twter().Nick, "")
} else {
image = URLForExternalAvatar(s.config, twt.Twter().URL)
image = URLForExternalAvatar(s.config, twt.Twter().URI)
}
when := twt.Created().Format(time.RFC3339)
@ -103,7 +103,7 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Last-Modified", twt.Created().Format(http.TimeFormat))
if strings.HasPrefix(twt.Twter().URL, s.config.BaseURL) {
if strings.HasPrefix(twt.Twter().URI, s.config.BaseURL) {
w.Header().Set(
"Link",
fmt.Sprintf(
@ -130,21 +130,21 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
URL: URLForTwt(s.config.BaseURL, hash),
Keywords: strings.Join(ks, ", "),
}
if strings.HasPrefix(twt.Twter().URL, s.config.BaseURL) {
if strings.HasPrefix(twt.Twter().URI, s.config.BaseURL) {
ctx.Links = append(ctx.Links, Link{
Href: fmt.Sprintf("%s/webmention", UserURL(twt.Twter().URL)),
Href: fmt.Sprintf("%s/webmention", UserURL(twt.Twter().URI)),
Rel: "webmention",
})
ctx.Alternatives = append(ctx.Alternatives, Alternatives{
Alternative{
Type: "text/plain",
Title: fmt.Sprintf("%s's Twtxt Feed", twt.Twter().Nick),
URL: twt.Twter().URL,
URL: twt.Twter().URI,
},
Alternative{
Type: "application/atom+xml",
Title: fmt.Sprintf("%s's Atom Feed", twt.Twter().Nick),
URL: fmt.Sprintf("%s/atom.xml", UserURL(twt.Twter().URL)),
URL: fmt.Sprintf("%s/atom.xml", UserURL(twt.Twter().URI)),
},
}...)
}

30
internal/theme/templates/partials.html

@ -71,19 +71,19 @@
<article id="{{ $.Twt.Hash }}" class="h-entry">
<div class="u-author h-card">
<div>
{{ if $.User.Is $.Twt.Twter.URL }}
{{ if $.User.Is $.Twt.Twter.URI }}
<a href="{{ $.User.URL | trimSuffix "/twtxt.txt" }}" class="u-url">
<img class="avatar u-photo" src="/user/{{ $.User.Username }}/avatar" alt="" loading=lazy />
</a>
{{ else }}
{{ if isLocalURL $.Twt.Twter.URL }}
<a href="{{ $.Twt.Twter.URL | trimSuffix "/twtxt.txt" }}" class="u-url">
{{ if isLocalURL $.Twt.Twter.URI }}
<a href="{{ $.Twt.Twter.URI | trimSuffix "/twtxt.txt" }}" class="u-url">
<img class="avatar u-photo" src="/user/{{ $.Twt.Twter.Nick }}/avatar" alt="" loading=lazy />
</a>
{{ else }}
<a href="/external?uri={{ $.Twt.Twter.URL }}&nick={{ $.Twt.Twter.Nick }}" class="u-url">
<a href="/external?uri={{ $.Twt.Twter.URI }}&nick={{ $.Twt.Twter.Nick }}" class="u-url">
{{ if $.Twt.Twter.Avatar }}
<img class="avatar u-photo" src="/externalAvatar?uri={{ $.Twt.Twter.URL }}" alt="" loading=lazy />
<img class="avatar u-photo" src="/externalAvatar?uri={{ $.Twt.Twter.URI }}" alt="" loading=lazy />
{{ else }}
<i class="ti ti-rss" style="font-size:3em"></i>
{{ end }}
@ -93,14 +93,14 @@
</div>
<div class="author">
<div class="p-name">
{{ if isLocalURL $.Twt.Twter.URL }}
<a href="{{ $.Twt.Twter.URL | trimSuffix "/twtxt.txt" }}">{{ $.Twt.Twter.Nick }}</a>
{{ if isLocalURL $.Twt.Twter.URI }}
<a href="{{ $.Twt.Twter.URI | trimSuffix "/twtxt.txt" }}">{{ $.Twt.Twter.Nick }}</a>
{{ else }}
<a href="/external?uri={{ $.Twt.Twter.URL }}&nick={{ $.Twt.Twter.Nick }}">{{ $.Twt.Twter.Nick }}</a>
<a href="/external?uri={{ $.Twt.Twter.URI }}&nick={{ $.Twt.Twter.Nick }}">{{ $.Twt.Twter.Nick }}</a>
{{ end }}
</div>
<div class="p-org">
<a target="_blank" href="{{ $.Twt.Twter.URL | baseFromURL }}">{{ $.Twt.Twter.URL | hostnameFromURL }}</a>
<a target="_blank" href="{{ $.Twt.Twter.URI | baseFromURL }}">{{ $.Twt.Twter.URI | hostnameFromURL }}</a>
</div>
<div class="publish-time">
<a class="u-url" href="/twt/{{ $.Twt.Hash }}">
@ -204,11 +204,11 @@
<ul>
<li>
{{ if $.Pager.HasPrev }}
{{ with $.Ctx.Twter.URL }}
{{ if isLocalURL $.Ctx.Twter.URL }}
{{ with $.Ctx.Twter.URI }}
{{ if isLocalURL $.Ctx.Twter.URI }}
<a href="?p={{ $.Pager.PrevPage }}"><i class="ti ti-caret-left"></i>&nbsp;{{tr $.Ctx "PagerPrevLinkTitle"}}</a>
{{ else }}
<a href="/external?uri={{ $.Ctx.Twter.URL }}&nick={{ $.Ctx.Twter.Nick }}&p={{ $.Pager.PrevPage }}"><i class="ti ti-caret-left"></i>&nbsp;{{tr $.Ctx "PagerPrevLinkTitle"}}</a>
<a href="/external?uri={{ $.Ctx.Twter.URI }}&nick={{ $.Ctx.Twter.Nick }}&p={{ $.Pager.PrevPage }}"><i class="ti ti-caret-left"></i>&nbsp;{{tr $.Ctx "PagerPrevLinkTitle"}}</a>
{{ end }}
{{ else }}
<a href="?p={{ $.Pager.PrevPage }}"><i class="ti ti-caret-left"></i>&nbsp;{{tr $.Ctx "PagerPrevLinkTitle"}}</a>
@ -223,11 +223,11 @@
<ul>
<li>
{{ if $.Pager.HasNext }}
{{ with $.Ctx.Twter.URL }}
{{ if isLocalURL $.Ctx.Twter.URL }}
{{ with $.Ctx.Twter.URI }}
{{ if isLocalURL $.Ctx.Twter.URI }}
<a href="?p={{ $.Pager.NextPage }}">{{tr $.Ctx "PagerNextLinkTitle"}}&nbsp;<i class="ti ti-caret-right"></i></a>
{{ else }}
<a href="/external?uri={{ $.Ctx.Twter.URL }}&nick={{ $.Ctx.Twter.Nick }}&p={{ $.Pager.NextPage }}">{{tr $.Ctx "PagerNextLinkTitle"}}&nbsp;<i class="ti ti-caret-right"></i></a>
<a href="/external?uri={{ $.Ctx.Twter.URI }}&nick={{ $.Ctx.Twter.Nick }}&p={{ $.Pager.NextPage }}">{{tr $.Ctx "PagerNextLinkTitle"}}&nbsp;<i class="ti ti-caret-right"></i></a>
{{ end }}
{{ else }}
<a href="?p={{ $.Pager.NextPage }}">{{tr $.Ctx "PagerNextLinkTitle"}}&nbsp;<i class="ti ti-caret-right"></i></a>

7
internal/twt.go

@ -118,7 +118,8 @@ func GetLastTwt(conf *Config, user *User) (twt types.Twt, offset int, err error)
return
}
twt, err = types.ParseLine(string(data), user.Twter())
twter := user.Twter()
twt, err = types.ParseLine(string(data), &twter)
return
}
@ -179,7 +180,7 @@ func GetAllTwts(conf *Config, name string) (types.Twts, error) {
twter := types.Twter{
Nick: name,
URL: URLForUser(conf.BaseURL, name),
URI: URLForUser(conf.BaseURL, name),
}
fn := filepath.Join(p, name)
f, err := os.Open(fn)
@ -188,7 +189,7 @@ func GetAllTwts(conf *Config, name string) (types.Twts, error) {
return nil, err
}
defer f.Close()
t, err := types.ParseFile(f, twter)
t, err := types.ParseFile(f, &twter)
if err != nil {
log.WithError(err).Errorf("error processing feed %s", fn)
return nil, err

26
internal/utils.go

@ -195,7 +195,7 @@ func ReplaceExt(fn, newExt string) string {
}
func HasExternalAvatarChanged(conf *Config, twter types.Twter) bool {
uri := NormalizeURL(twter.URL)
uri := NormalizeURL(twter.URI)
slug := Slugify(uri)
fn := filepath.Join(conf.Data, externalDir, fmt.Sprintf("%s.cbf", slug))
@ -228,7 +228,7 @@ func HasExternalAvatarChanged(conf *Config, twter types.Twter) bool {
}
func GetExternalAvatar(conf *Config, twter types.Twter) string {
uri := NormalizeURL(twter.URL)
uri := NormalizeURL(twter.URI)
slug := Slugify(uri)
fn := filepath.Join(conf.Data, externalDir, fmt.Sprintf("%s.png", slug))
@ -979,24 +979,24 @@ func NormalizeFeedName(name string) string {
return name
}
func ValidateFeed(conf *Config, nick, url string) (types.Twter, error) {
func ValidateFeed(conf *Config, nick, url string) (*types.Twter, error) {
var body io.ReadCloser
if strings.HasPrefix(url, "gopher://") {
res, err := RequestGopher(conf, url)
if err != nil {
log.WithError(err).Errorf("error fetching feed %s", url)
return types.Twter{}, err
return nil, err
}
body = res.Body
} else {
res, err := Request(conf, http.MethodGet, url, nil)
if err != nil {
log.WithError(err).Errorf("error fetching feed %s", url)
return types.Twter{}, err
return nil, err
}
if res.StatusCode != 200 {
return types.Twter{}, ErrBadRequest
return nil, ErrBadRequest
}
body = res.Body
}
@ -1004,10 +1004,10 @@ func ValidateFeed(conf *Config, nick, url string) (types.Twter, error) {
defer body.Close()
limitedReader := &io.LimitedReader{R: body, N: conf.MaxFetchLimit}
twter := types.Twter{Nick: nick, URL: url}
tf, err := types.ParseFile(limitedReader, twter)
twter := types.Twter{Nick: nick, URI: url}
tf, err := types.ParseFile(limitedReader, &twter)
if err != nil {
return types.Twter{}, err
return nil, err
}
return tf.Twter(), nil
@ -1884,7 +1884,7 @@ func GetRootTwtFactory(conf *Config, cache *Cache, archive Archiver) func(twt ty
return types.NilTwt
}
if u.HasMuted(rootTwt.Twter().URL) {
if u.HasMuted(rootTwt.Twter().URI) {
return types.NilTwt
}
@ -2117,16 +2117,16 @@ func NewFeedLookup(conf *Config, db Store, user *User) types.FeedLookup {
parts := strings.SplitN(followedAs, "@", 2)
if len(parts) == 2 && u.Hostname() == parts[1] {
return &types.Twter{Nick: parts[0], URL: followedURL}
return &types.Twter{Nick: parts[0], URI: followedURL}
}
return &types.Twter{Nick: followedAs, URL: followedURL}
return &types.Twter{Nick: followedAs, URI: followedURL}
}
}
username := NormalizeUsername(alias)
if db.HasUser(username) || db.HasFeed(username) {
return &types.Twter{Nick: username, URL: URLForUser(conf.BaseURL, username)}
return &types.Twter{Nick: username, URI: URLForUser(conf.BaseURL, username)}
}
return &types.Twter{}

2
internal/utils_test.go

@ -235,7 +235,7 @@ func TestFormatTwtFactory(t *testing.T) {
factory := FormatTwtFactory(cfg, NewCache(cfg), &NullArchiver{})
twter := types.Twter{
Nick: "test",
URL: "https://example.com/twtxt.txt",
URI: "https://example.com/twtxt.txt",
}
txt := factory(lextwt.NewTwt(twter,
lextwt.NewDateTime(parseTime("2021-01-24T02:19:54Z"), "2021-01-24T02:19:54Z"),

25
types/lextwt/ast.go

@ -114,7 +114,7 @@ func (lis Comments) FollowMap() map[string]types.Twter {
if len(sp) < 2 {
continue
}
nmap[sp[0]] = types.Twter{Nick: sp[0], URL: sp[1]}
nmap[sp[0]] = types.Twter{Nick: sp[0], URI: sp[1]}
}
return nmap
@ -129,7 +129,7 @@ func (lis Comments) Following() []types.Twter {
if len(sp) < 2 {
continue
}
nlis = append(nlis, types.Twter{Nick: sp[0], URL: sp[1]})
nlis = append(nlis, types.Twter{Nick: sp[0], URI: sp[1]})
}
return nlis
@ -215,7 +215,7 @@ func (n *Mention) Clone() Elem {
}
}
func (n *Mention) IsNil() bool { return n == nil }
func (n *Mention) Twter() types.Twter { return types.Twter{Nick: n.name, URL: n.target} }
func (n *Mention) Twter() types.Twter { return types.Twter{Nick: n.name, URI: n.target} }
func (n *Mention) Literal() string { return n.lit }
func (n *Mention) String() string { return n.lit }
func (n *Mention) Name() string { return n.name }
@ -733,7 +733,7 @@ func (twt *Twt) append(elem Elem) {
if m, ok := twt.msg[0].(*Mention); ok {
text.lit = text.lit[1:]
twt.msg = twt.msg[1:]
twt.twter = &types.Twter{Nick: m.Name(), URL: m.Target()}
twt.twter = &types.Twter{Nick: m.Name(), URI: m.Target()}
twt.isProxy = true
}
}
@ -782,7 +782,7 @@ func (twt *Twt) GobEncode() ([]byte, error) {
s := fmt.Sprintf(
"%s\t%s\t%s\t%s\t%s",
twter.Nick,
twter.URL,
twter.URI,
twter.Avatar,
twt.Hash(),
twt.Literal(),
@ -794,9 +794,9 @@ func (twt *Twt) GobDecode(data []byte) error {
if len(sp) != 5 {
return fmt.Errorf("unable to decode twt: %s ", data)
}
twter := types.Twter{Nick: sp[0], URL: sp[1], Avatar: sp[2]}
twter := types.Twter{Nick: sp[0], URI: sp[1], Avatar: sp[2]}
twt.hash = sp[3]
t, err := ParseLine(sp[4], twter)
t, err := ParseLine(sp[4], &twter)
if err != nil {
return err
}
@ -874,7 +874,7 @@ func (twt Twt) Format(state fmt.State, c rune) {
state.Write([]byte("\t"))
}
if state.Flag('#') || twt.isProxy {
fmt.Fprint(state, NewMention(twt.twter.Nick, twt.twter.URL))
fmt.Fprint(state, NewMention(twt.twter.Nick, twt.twter.URI))
state.Write([]byte("\t"))
}
@ -957,7 +957,7 @@ func (twt *Twt) ExpandMentions(opts types.FmtOpts, lookup types.FeedLookup) {
} else {
m.name = twter.Nick
}
m.target = twter.URL
m.target = twter.URI
}
}
@ -998,9 +998,14 @@ func (twt Twt) Hash() string {
return twt.hash
}
hashingURI := twt.Twter().HashingURI
if hashingURI == "" {
hashingURI = twt.Twter().URI
}
payload := fmt.Sprintf(
"%s\n%s\n%s",
twt.Twter().URL,
hashingURI,
twt.Created().Format(time.RFC3339),
twt.LiteralText(),
)

28
types/lextwt/lextwt.go

@ -17,20 +17,20 @@ func init() {
}
// ParseFile and return time & count limited twts + comments
func ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
twterURI, err := url.Parse(twter.URL)
func ParseFile(r io.Reader, twter *types.Twter) (types.TwtFile, error) {
twterURI, err := url.Parse(twter.URI)
if err != nil {
log.WithError(err).Errorf("error bad twter url %s", twter.URL)
log.WithError(err).Errorf("error bad twter url %s", twter.URI)
return nil, types.ErrInvalidFeed
}
f := &lextwtFile{twter: &twter}
f := &lextwtFile{twter: twter}
nTwts, nErrors := 0, 0
lexer := NewLexer(r)
parser := NewParser(lexer)
parser.SetTwter(&twter)
parser.SetTwter(twter)
for !parser.IsEOF() {
line := parser.ParseLine()
@ -40,7 +40,7 @@ func ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
f.comments = append(f.comments, e)
case *Twt:
if e.IsNil() {
log.Errorf("invalid feed or bad line parsing %#v", twter.URL)
log.Errorf("invalid feed or bad line parsing %#v", twter.URI)
nErrors++
continue
}
@ -49,10 +49,10 @@ func ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
f.twts = append(f.twts, e)
// If the twt has an override twter add to authors.
if e.twter.URL != f.twter.URL {
if e.twter.URI != f.twter.URI {
found := false
for i := range f.twters {
if f.twters[i].URL == e.twter.URL {
if f.twters[i].URI == e.twter.URI {
found = true
// de-dup the elements twter with the file one.
e.twter = f.twters[i]
@ -81,7 +81,7 @@ func ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
if u.Scheme == "" {
u.Scheme = twterURI.Scheme
}
f.twter.URL = u.String()
f.twter.HashingURI = u.String()
}
}
@ -116,7 +116,7 @@ func ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
return f, nil
}
func ParseLine(line string, twter types.Twter) (twt types.Twt, err error) {
func ParseLine(line string, twter *types.Twter) (twt types.Twt, err error) {
if line == "" {
return types.NilTwt, nil
}
@ -124,7 +124,7 @@ func ParseLine(line string, twter types.Twter) (twt types.Twt, err error) {
r := strings.NewReader(line)
lexer := NewLexer(r)
parser := NewParser(lexer)
parser.SetTwter(&twter)
parser.SetTwter(twter)
twt = parser.ParseTwt()
@ -138,10 +138,10 @@ func ParseLine(line string, twter types.Twter) (twt types.Twt, err error) {
type lextwtManager struct{}
func (*lextwtManager) DecodeJSON(b []byte) (types.Twt, error) { return DecodeJSON(b) }
func (*lextwtManager) ParseLine(line string, twter types.Twter) (twt types.Twt, err error) {
func (*lextwtManager) ParseLine(line string, twter *types.Twter) (twt types.Twt, err error) {
return ParseLine(line, twter)
}
func (*lextwtManager) ParseFile(r io.Reader, twter types.Twter) (types.TwtFile, error) {
func (*lextwtManager) ParseFile(r io.Reader, twter *types.Twter) (types.TwtFile, error) {
return ParseFile(r, twter)
}
func (*lextwtManager) MakeTwt(twter types.Twter, ts time.Time, text string) types.Twt {
@ -171,7 +171,7 @@ var _ types.TwtFile = (*lextwtFile)(nil)
func NewTwtFile(twter types.Twter, comments Comments, twts types.Twts) *lextwtFile {
return &lextwtFile{&twter, []*types.Twter{&twter}, twts, comments}
}
func (r *lextwtFile) Twter() types.Twter { return *r.twter }
func (r *lextwtFile) Twter() *types.Twter { return r.twter }
func (r *lextwtFile) Authors() []*types.Twter { return r.twters }
func (r *lextwtFile) Info() types.Info { return r.comments }
func (r *lextwtFile) Twts() types.Twts { return r.twts }

35
types/lextwt/lextwt_test.go

@ -479,7 +479,7 @@ type twtTestCase struct {
}
func TestParseTwt(t *testing.T) {
twter := types.Twter{Nick: "example", URL: "http://example.com/example.txt"}
twter := types.Twter{Nick: "example", URI: "http://example.com/example.txt"}
tests := []twtTestCase{
{
lit: "2016-02-03T23:03:00+00:00 @<example http://example.org/twtxt.txt>\u2028welcome to twtxt!\n",
@ -644,9 +644,9 @@ func TestParseTwt(t *testing.T) {
{
lit: `2021-02-04T12:54:21Z @<other http://example.com/other.txt> example`,
twter: &types.Twter{Nick: "other", URL: "http://example.com/other.txt"},
twter: &types.Twter{Nick: "other", URI: "http://example.com/other.txt"},
twt: lextwt.NewTwt(
types.Twter{Nick: "other", URL: "http://example.com/other.txt"},
types.Twter{Nick: "other", URI: "http://example.com/other.txt"},
lextwt.NewDateTime(parseTime("2021-02-04T12:54:21Z"), "2021-02-04T12:54:21Z"),
lextwt.NewMention("other", "http://example.com/other.txt"),
lextwt.NewText("\texample"),
@ -748,7 +748,7 @@ func TestParseTwt(t *testing.T) {
}
if tt.twter != nil {
is.Equal(twt.Twter().Nick, tt.twter.Nick)
is.Equal(twt.Twter().URL, tt.twter.URL)
is.Equal(twt.Twter().URI, tt.twter.URI)
}
})
}
@ -931,7 +931,7 @@ func TestParseText(t *testing.T) {
type fileTestCase struct {
in io.Reader
twter types.Twter
twter *types.Twter
override *types.Twter
out types.TwtFile
err error
@ -940,17 +940,18 @@ type fileTestCase struct {
func TestParseFile(t *testing.T) {
is := is.New(t)
twter := types.Twter{Nick: "example", URL: "https://example.com/twtxt.txt"}
twter := types.Twter{Nick: "example", URI: "https://example.com/twtxt.txt"}
override := types.Twter{
Nick: "override",
URL: "https://example.com/twtxt.txt",
Following: 1,
Follow: map[string]types.Twter{"xuu@txt.sour.is": {Nick: "xuu@txt.sour.is", URL: "https://txt.sour.is/users/xuu.txt"}},
Nick: "override",
URI: "https://example.com/twtxt.txt",
HashingURI: "https://example.com/twtxt.txt",
Following: 1,
Follow: map[string]types.Twter{"xuu@txt.sour.is": {Nick: "xuu@txt.sour.is", URI: "https://txt.sour.is/users/xuu.txt"}},
}
tests := []fileTestCase{
{
twter: twter,
twter: &twter,
override: &override,
in: strings.NewReader(`# My Twtxt!
# nick = override
@ -1006,7 +1007,7 @@ func TestParseFile(t *testing.T) {
),
},
{
twter: twter,
twter: &twter,
in: strings.NewReader(`2016-02-03`),
out: lextwt.NewTwtFile(
twter,
@ -1030,7 +1031,7 @@ func TestParseFile(t *testing.T) {
is.True(f != nil)
if tt.override != nil {
is.Equal(*tt.override, f.Twter())
is.Equal(tt.override, f.Twter())
}
{
@ -1074,7 +1075,7 @@ type testExpandLinksCase struct {
}
func TestExpandLinks(t *testing.T) {
twter := types.Twter{Nick: "example", URL: "http://example.com/example.txt"}
twter := types.Twter{Nick: "example", URI: "http://example.com/example.txt"}
conf := mockFmtOpts{
localURL: "http://example.com",
}
@ -1086,7 +1087,7 @@ func TestExpandLinks(t *testing.T) {
lextwt.NewDateTime(parseTime("2021-01-24T02:19:54Z"), "2021-01-24T02:19:54Z"),
lextwt.NewMention("@asdf", ""),
),
target: &types.Twter{Nick: "asdf", URL: "http://example.com/asdf.txt"},
target: &types.Twter{Nick: "asdf", URI: "http://example.com/asdf.txt"},
},
}
@ -1096,7 +1097,7 @@ func TestExpandLinks(t *testing.T) {
lookup := types.FeedLookupFn(func(s string) *types.Twter { return tt.target })
tt.twt.ExpandMentions(conf, lookup)
is.Equal(tt.twt.Mentions()[0].Twter().Nick, tt.target.Nick)
is.Equal(tt.twt.Mentions()[0].Twter().URL, tt.target.URL)
is.Equal(tt.twt.Mentions()[0].Twter().URI, tt.target.URI)
}
}
@ -1138,7 +1139,7 @@ func (m mockFmtOpts) URLForUser(username string) string {
// func TestSomethingWeird(t *testing.T) {
// is := is.New(t)
// twter := types.Twter{Nick: "prologic", URL: "https://twtxt.net/user/prologic/twtxt.txt"}
// twter := types.Twter{Nick: "prologic", RequestURI: "https://twtxt.net/user/prologic/twtxt.txt"}
// res, err := http.Get("https://twtxt.net/user/prologic/twtxt.txt")
// is.NoErr(err)

65
types/twt.go

@ -18,22 +18,34 @@ const (
// Twter ...
type Twter struct {
Nick string
URL string
Avatar string
Tagline string
Nick string
URI string
HashingURI string
// URL Deprecated field repalced by URI
// Remove post Cache v20
URL string
Avatar string
Tagline string
Following int
Followers int
Follow map[string]Twter
Follow map[string]Twter
}
func (twter Twter) IsZero() bool {
return twter.Nick == "" && twter.URL == ""
return twter.Nick == "" && twter.URI == ""
}
func (twter Twter) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Nick string `json:"nick"`
Nick string `json:"nick"`
URI string `json:"uri"`
HashingURI string `json:"hashing_uri"`
// URL Deprecated and maintained for backwards compatibility with APIv1
// Remove in APIv2
URL string `json:"url"`
Avatar string `json:"avatar"`
Tagline string `json:"tagline"`
@ -41,22 +53,25 @@ func (twter Twter) MarshalJSON() ([]byte, error) {
Followers int `json:"followers"`
Follow map[string]Twter `json:"follow"`
}{
Nick: twter.Nick,
URL: twter.URL,
Avatar: twter.Avatar,
Tagline: twter.Tagline,
Following: twter.Following,
Followers: twter.Followers,
Follow: twter.Follow,
Nick: twter.Nick,
URI: twter.URI,
HashingURI: twter.HashingURI,
URL: twter.URI,
Avatar: twter.Avatar,
Tagline: twter.Tagline,
Following: twter.Following,
Followers: twter.Followers,
Follow: twter.Follow,
})
}
func (twter Twter) String() string { return fmt.Sprintf("%v\t%v", twter.Nick, twter.URL) }