github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/topic.go (about)

     1  /*
     2  *
     3  *	Gosora Topic File
     4  *	Copyright Azareal 2017 - 2020
     5  *
     6   */
     7  package common
     8  
     9  import (
    10  	"database/sql"
    11  	"html"
    12  	"html/template"
    13  
    14  	"strconv"
    15  	"strings"
    16  	"time"
    17  
    18  	//"log"
    19  
    20  	p "github.com/Azareal/Gosora/common/phrases"
    21  	qgen "github.com/Azareal/Gosora/query_gen"
    22  )
    23  
    24  // This is also in reply.go
    25  //var ErrAlreadyLiked = errors.New("This item was already liked by this user")
    26  
    27  // ? - Add a TopicMeta struct for *Forums?
    28  
    29  type Topic struct {
    30  	ID          int
    31  	Link        string
    32  	Title       string
    33  	Content     string
    34  	CreatedBy   int
    35  	IsClosed    bool
    36  	Sticky      bool
    37  	CreatedAt   time.Time
    38  	LastReplyAt time.Time
    39  	LastReplyBy int
    40  	LastReplyID int
    41  	ParentID    int
    42  	Status      string // Deprecated. Marked for removal. -Is there anything we could use it for?
    43  	IP          string
    44  	ViewCount   int64
    45  	PostCount   int
    46  	LikeCount   int
    47  	AttachCount int
    48  	WeekViews   int
    49  	ClassName   string // CSS Class Name
    50  	Poll        int
    51  	Data        string // Used for report metadata
    52  
    53  	Rids []int
    54  }
    55  
    56  type TopicUser struct {
    57  	ID          int
    58  	Link        string
    59  	Title       string
    60  	Content     string // TODO: Avoid converting this to bytes in templates, particularly if it's long
    61  	CreatedBy   int
    62  	IsClosed    bool
    63  	Sticky      bool
    64  	CreatedAt   time.Time
    65  	LastReplyAt time.Time
    66  	LastReplyBy int
    67  	LastReplyID int
    68  	ParentID    int
    69  	Status      string // Deprecated. Marked for removal.
    70  	IP          string
    71  	ViewCount   int64
    72  	PostCount   int
    73  	LikeCount   int
    74  	AttachCount int
    75  	ClassName   string
    76  	Poll        int
    77  	Data        string // Used for report metadata
    78  
    79  	UserLink      string
    80  	CreatedByName string
    81  	Group         int
    82  	Avatar        string
    83  	MicroAvatar   string
    84  	ContentLines  int
    85  	ContentHTML   string // TODO: Avoid converting this to bytes in templates, particularly if it's long
    86  	Tag           string
    87  	URL           string
    88  	//URLPrefix     string
    89  	//URLName       string
    90  	Level int
    91  	Liked bool
    92  
    93  	Attachments []*MiniAttachment
    94  	Rids        []int
    95  	Deletable   bool
    96  }
    97  
    98  type TopicsRowMut struct {
    99  	*TopicsRow
   100  	CanMod bool
   101  }
   102  
   103  // TODO: Embed TopicUser to simplify this structure and it's related logic?
   104  type TopicsRow struct {
   105  	Topic
   106  	LastPage int
   107  
   108  	Creator      *User
   109  	CSS          template.CSS
   110  	ContentLines int
   111  	LastUser     *User
   112  
   113  	ForumName string //TopicsRow
   114  	ForumLink string
   115  }
   116  
   117  type WsTopicsRow struct {
   118  	ID                  int
   119  	Link                string
   120  	Title               string
   121  	CreatedBy           int
   122  	IsClosed            bool
   123  	Sticky              bool
   124  	CreatedAt           time.Time
   125  	LastReplyAt         time.Time
   126  	RelativeLastReplyAt string
   127  	LastReplyBy         int
   128  	LastReplyID         int
   129  	ParentID            int
   130  	ViewCount           int64
   131  	PostCount           int
   132  	LikeCount           int
   133  	AttachCount         int
   134  	ClassName           string
   135  	Creator             *WsJSONUser
   136  	LastUser            *WsJSONUser
   137  	ForumName           string
   138  	ForumLink           string
   139  	CanMod              bool
   140  }
   141  
   142  // TODO: Can we get the client side to render the relative times instead?
   143  func (r *TopicsRow) WebSockets() *WsTopicsRow {
   144  	return &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, false}
   145  }
   146  
   147  // TODO: Can we get the client side to render the relative times instead?
   148  func (r *TopicsRow) WebSockets2(canMod bool) *WsTopicsRow {
   149  	return &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, canMod}
   150  }
   151  
   152  // TODO: Stop relying on so many struct types?
   153  // ! Not quite safe as Topic doesn't contain all the data needed to constructs a TopicsRow
   154  func (t *Topic) TopicsRow() *TopicsRow {
   155  	lastPage := 1
   156  	var creator *User = nil
   157  	contentLines := 1
   158  	var lastUser *User = nil
   159  	forumName := ""
   160  	forumLink := ""
   161  
   162  	//return &TopicsRow{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, lastPage, t.ClassName, t.Poll, t.Data, creator, "", contentLines, lastUser, forumName, forumLink, t.Rids}
   163  	return &TopicsRow{*t, lastPage, creator, "", contentLines, lastUser, forumName, forumLink}
   164  }
   165  
   166  // ! Some data may be lost in the conversion
   167  /*func (t *TopicsRow) Topic() *Topic {
   168  	//return &Topic{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, t.Poll, t.Data, t.Rids}
   169  	return &t.Topic
   170  }*/
   171  
   172  // ! Not quite safe as Topic doesn't contain all the data needed to constructs a WsTopicsRow
   173  /*func (t *Topic) WsTopicsRows() *WsTopicsRow {
   174  	var creator *User = nil
   175  	var lastUser *User = nil
   176  	forumName := ""
   177  	forumLink := ""
   178  	return &WsTopicsRow{t.ID, t.Link, t.Title, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, RelativeTime(t.LastReplyAt), t.LastReplyBy, t.LastReplyID, t.ParentID, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, creator, lastUser, forumName, forumLink}
   179  }*/
   180  
   181  type TopicStmts struct {
   182  	getRids             *sql.Stmt
   183  	getReplies          *sql.Stmt
   184  	getReplies2         *sql.Stmt
   185  	getReplies3         *sql.Stmt
   186  	addReplies          *sql.Stmt
   187  	updateLastReply     *sql.Stmt
   188  	lock                *sql.Stmt
   189  	unlock              *sql.Stmt
   190  	moveTo              *sql.Stmt
   191  	stick               *sql.Stmt
   192  	unstick             *sql.Stmt
   193  	hasLikedTopic       *sql.Stmt
   194  	createLike          *sql.Stmt
   195  	addLikesToTopic     *sql.Stmt
   196  	delete              *sql.Stmt
   197  	deleteReplies       *sql.Stmt
   198  	deleteLikesForTopic *sql.Stmt
   199  	deleteActivity      *sql.Stmt
   200  	edit                *sql.Stmt
   201  	setPoll             *sql.Stmt
   202  	removePoll          *sql.Stmt
   203  	testSetCreatedAt    *sql.Stmt
   204  	createAction        *sql.Stmt
   205  
   206  	getTopicUser *sql.Stmt // TODO: Can we get rid of this?
   207  	getByReplyID *sql.Stmt
   208  }
   209  
   210  var topicStmts TopicStmts
   211  
   212  func init() {
   213  	DbInits.Add(func(acc *qgen.Accumulator) error {
   214  		t, w := "topics", "tid=?"
   215  		set := func(s string) *sql.Stmt {
   216  			return acc.Update(t).Set(s).Where(w).Prepare()
   217  		}
   218  		topicStmts = TopicStmts{
   219  			getRids:             acc.Select("replies").Columns("rid").Where(w).Orderby("rid ASC").Limit("?,?").Prepare(),
   220  			getReplies:          acc.SimpleLeftJoin("replies AS r", "users AS u", "r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.ip, r.likeCount, r.attachCount, r.actionType", "r.createdBy=u.uid", "r.tid=?", "r.rid ASC", "?,?"),
   221  			getReplies2:         acc.SimpleLeftJoin("replies AS r", "users AS u", "r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.likeCount, r.attachCount, r.actionType", "r.createdBy=u.uid", "r.tid=?", "r.rid ASC", "?,?"),
   222  			getReplies3:         acc.Select("replies").Columns("rid,content,createdBy,createdAt,lastEdit,lastEditBy,likeCount,attachCount,actionType").Where(w).Orderby("rid ASC").Limit("?,?").Prepare(),
   223  			addReplies:          set("postCount=postCount+?,lastReplyBy=?,lastReplyAt=UTC_TIMESTAMP()"),
   224  			updateLastReply:     acc.Update(t).Set("lastReplyID=?").Where("lastReplyID<? AND tid=?").Prepare(),
   225  			lock:                set("is_closed=1"),
   226  			unlock:              set("is_closed=0"),
   227  			moveTo:              set("parentID=?"),
   228  			stick:               set("sticky=1"),
   229  			unstick:             set("sticky=0"),
   230  			hasLikedTopic:       acc.Select("likes").Columns("targetItem").Where("sentBy=? and targetItem=? and targetType='topics'").Prepare(),
   231  			createLike:          acc.Insert("likes").Columns("weight,targetItem,targetType,sentBy,createdAt").Fields("?,?,?,?,UTC_TIMESTAMP()").Prepare(),
   232  			addLikesToTopic:     set("likeCount=likeCount+?"),
   233  			delete:              acc.Delete(t).Where(w).Prepare(),
   234  			deleteReplies:       acc.Delete("replies").Where(w).Prepare(),
   235  			deleteLikesForTopic: acc.Delete("likes").Where("targetItem=? AND targetType='topics'").Prepare(),
   236  			deleteActivity:      acc.Delete("activity_stream").Where("elementID=? AND elementType='topic'").Prepare(),
   237  			edit:                set("title=?,content=?,parsed_content=?"), // TODO: Only run the content update bits on non-polls, does this matter?
   238  			setPoll:             acc.Update(t).Set("poll=?").Where("tid=? AND poll=0").Prepare(),
   239  			removePoll:          acc.Update(t).Set("poll=0").Where("tid=?").Prepare(),
   240  			testSetCreatedAt:    set("createdAt=?"),
   241  			createAction:        acc.Insert("replies").Columns("tid,actionType,ip,createdBy,createdAt,lastUpdated,content,parsed_content").Fields("?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),'',''").Prepare(),
   242  
   243  			getTopicUser: acc.SimpleLeftJoin("topics AS t", "users AS u", "t.title, t.content, t.createdBy, t.createdAt, t.lastReplyAt, t.lastReplyBy, t.lastReplyID, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.attachCount,t.poll, u.name, u.avatar, u.group, u.level", "t.createdBy=u.uid", w, "", ""),
   244  			getByReplyID: acc.SimpleLeftJoin("replies AS r", "topics AS t", "t.tid, t.title, t.content, t.createdBy, t.createdAt, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.poll, t.data", "r.tid=t.tid", "rid=?", "", ""),
   245  		}
   246  		return acc.FirstError()
   247  	})
   248  }
   249  
   250  // Flush the topic out of the cache
   251  // ? - We do a CacheRemove() here instead of mutating the pointer to avoid creating a race condition
   252  func (t *Topic) cacheRemove() {
   253  	if tc := Topics.GetCache(); tc != nil {
   254  		tc.Remove(t.ID)
   255  	}
   256  	TopicListThaw.Thaw()
   257  }
   258  
   259  // TODO: Write a test for this
   260  func (t *Topic) AddReply(rid, uid int) (e error) {
   261  	_, e = topicStmts.addReplies.Exec(1, uid, t.ID)
   262  	if e != nil {
   263  		return e
   264  	}
   265  	_, e = topicStmts.updateLastReply.Exec(rid, rid, t.ID)
   266  	t.cacheRemove()
   267  	return e
   268  }
   269  
   270  func (t *Topic) Lock() (e error) {
   271  	_, e = topicStmts.lock.Exec(t.ID)
   272  	t.cacheRemove()
   273  	return e
   274  }
   275  
   276  func (t *Topic) Unlock() (e error) {
   277  	_, e = topicStmts.unlock.Exec(t.ID)
   278  	t.cacheRemove()
   279  	return e
   280  }
   281  
   282  func (t *Topic) MoveTo(destForum int) (e error) {
   283  	_, e = topicStmts.moveTo.Exec(destForum, t.ID)
   284  	t.cacheRemove()
   285  	if e != nil {
   286  		return e
   287  	}
   288  	e = Attachments.MoveTo(destForum, t.ID, "topics")
   289  	if e != nil {
   290  		return e
   291  	}
   292  	return Attachments.MoveToByExtra(destForum, "replies", strconv.Itoa(t.ID))
   293  }
   294  
   295  func (t *Topic) TestSetCreatedAt(s time.Time) (e error) {
   296  	_, e = topicStmts.testSetCreatedAt.Exec(s, t.ID)
   297  	t.cacheRemove()
   298  	return e
   299  }
   300  
   301  // TODO: We might want more consistent terminology rather than using stick in some places and pin in others. If you don't understand the difference, there is none, they are one and the same.
   302  func (t *Topic) Stick() (e error) {
   303  	_, e = topicStmts.stick.Exec(t.ID)
   304  	t.cacheRemove()
   305  	return e
   306  }
   307  
   308  func (t *Topic) Unstick() (e error) {
   309  	_, e = topicStmts.unstick.Exec(t.ID)
   310  	t.cacheRemove()
   311  	return e
   312  }
   313  
   314  // TODO: Test this
   315  // TODO: Use a transaction for this
   316  func (t *Topic) Like(score, uid int) (err error) {
   317  	var disp int // Unused
   318  	err = topicStmts.hasLikedTopic.QueryRow(uid, t.ID).Scan(&disp)
   319  	if err != nil && err != ErrNoRows {
   320  		return err
   321  	} else if err != ErrNoRows {
   322  		return ErrAlreadyLiked
   323  	}
   324  	_, err = topicStmts.createLike.Exec(score, t.ID, "topics", uid)
   325  	if err != nil {
   326  		return err
   327  	}
   328  	_, err = topicStmts.addLikesToTopic.Exec(1, t.ID)
   329  	if err != nil {
   330  		return err
   331  	}
   332  	_, err = userStmts.incLiked.Exec(1, uid)
   333  	t.cacheRemove()
   334  	return err
   335  }
   336  
   337  // TODO: Use a transaction
   338  func (t *Topic) Unlike(uid int) error {
   339  	e := Likes.Delete(t.ID, "topics")
   340  	if e != nil {
   341  		return e
   342  	}
   343  	_, e = topicStmts.addLikesToTopic.Exec(-1, t.ID)
   344  	if e != nil {
   345  		return e
   346  	}
   347  	_, e = userStmts.decLiked.Exec(1, uid)
   348  	t.cacheRemove()
   349  	return e
   350  }
   351  
   352  func handleLikedTopicReplies(tid int) error {
   353  	rows, e := userStmts.getLikedRepliesOfTopic.Query(tid)
   354  	if e != nil {
   355  		return e
   356  	}
   357  	defer rows.Close()
   358  	for rows.Next() {
   359  		var rid int
   360  		if e := rows.Scan(&rid); e != nil {
   361  			return e
   362  		}
   363  		_, e = replyStmts.deleteLikesForReply.Exec(rid)
   364  		if e != nil {
   365  			return e
   366  		}
   367  		e = Activity.DeleteByParams("like", rid, "post")
   368  		if e != nil {
   369  			return e
   370  		}
   371  	}
   372  	return rows.Err()
   373  }
   374  
   375  func handleTopicAttachments(tid int) error {
   376  	e := handleAttachments(userStmts.getAttachmentsOfTopic, tid)
   377  	if e != nil {
   378  		return e
   379  	}
   380  	return handleAttachments(userStmts.getAttachmentsOfTopic2, tid)
   381  }
   382  
   383  func handleReplyAttachments(rid int) error {
   384  	return handleAttachments(replyStmts.getAidsOfReply, rid)
   385  }
   386  
   387  func handleAttachments(stmt *sql.Stmt, id int) error {
   388  	rows, e := stmt.Query(id)
   389  	if e != nil {
   390  		return e
   391  	}
   392  	defer rows.Close()
   393  	for rows.Next() {
   394  		var aid int
   395  		if e := rows.Scan(&aid); e != nil {
   396  			return e
   397  		}
   398  		a, e := Attachments.FGet(aid)
   399  		if e != nil {
   400  			return e
   401  		}
   402  		e = deleteAttachment(a)
   403  		if e != nil && e != sql.ErrNoRows {
   404  			return e
   405  		}
   406  	}
   407  	return rows.Err()
   408  }
   409  
   410  // TODO: Only load a row per createdBy, maybe with group by?
   411  func handleTopicReplies(umap map[int]struct{}, uid, tid int) error {
   412  	rows, e := userStmts.getRepliesOfTopic.Query(uid, tid)
   413  	if e != nil {
   414  		return e
   415  	}
   416  	defer rows.Close()
   417  	var createdBy int
   418  	for rows.Next() {
   419  		if e := rows.Scan(&createdBy); e != nil {
   420  			return e
   421  		}
   422  		umap[createdBy] = struct{}{}
   423  	}
   424  	return rows.Err()
   425  }
   426  
   427  // TODO: Use a transaction here
   428  func (t *Topic) Delete() error {
   429  	/*creator, e := Users.Get(t.CreatedBy)
   430  	if e == nil {
   431  		e = creator.DecreasePostStats(WordCount(t.Content), true)
   432  		if e != nil {
   433  			return e
   434  		}
   435  	} else if e != ErrNoRows {
   436  		return e
   437  	}*/
   438  
   439  	// TODO: Clear reply cache too
   440  	_, e := topicStmts.delete.Exec(t.ID)
   441  	t.cacheRemove()
   442  	if e != nil {
   443  		return e
   444  	}
   445  	e = Forums.RemoveTopic(t.ParentID)
   446  	if e != nil && e != ErrNoRows {
   447  		return e
   448  	}
   449  	_, e = topicStmts.deleteLikesForTopic.Exec(t.ID)
   450  	if e != nil {
   451  		return e
   452  	}
   453  
   454  	if t.PostCount > 1 {
   455  		if e = handleLikedTopicReplies(t.ID); e != nil {
   456  			return e
   457  		}
   458  		umap := make(map[int]struct{})
   459  		e = handleTopicReplies(umap, t.CreatedBy, t.ID)
   460  		if e != nil {
   461  			return e
   462  		}
   463  		_, e = topicStmts.deleteReplies.Exec(t.ID)
   464  		if e != nil {
   465  			return e
   466  		}
   467  		for uid := range umap {
   468  			e = (&User{ID: uid}).RecalcPostStats()
   469  			if e != nil {
   470  				//log.Printf("e: %+v\n", e)
   471  				return e
   472  			}
   473  		}
   474  	}
   475  	e = (&User{ID: t.CreatedBy}).RecalcPostStats()
   476  	if e != nil {
   477  		return e
   478  	}
   479  	e = handleTopicAttachments(t.ID)
   480  	if e != nil {
   481  		return e
   482  	}
   483  	e = Subscriptions.DeleteResource(t.ID, "topic")
   484  	if e != nil {
   485  		return e
   486  	}
   487  	_, e = topicStmts.deleteActivity.Exec(t.ID)
   488  	if e != nil {
   489  		return e
   490  	}
   491  	if t.Poll > 0 {
   492  		e = (&Poll{ID: t.Poll}).Delete()
   493  		if e != nil {
   494  			return e
   495  		}
   496  	}
   497  	return nil
   498  }
   499  
   500  // TODO: Write tests for this
   501  func (t *Topic) Update(name, content string) error {
   502  	name = SanitiseSingleLine(html.UnescapeString(name))
   503  	if name == "" {
   504  		return ErrNoTitle
   505  	}
   506  	// ? This number might be a little screwy with Unicode, but it's the only consistent thing we have, as Unicode characters can be any number of bytes in theory?
   507  	if len(name) > Config.MaxTopicTitleLength {
   508  		return ErrLongTitle
   509  	}
   510  
   511  	content = PreparseMessage(html.UnescapeString(content))
   512  	parsedContent := ParseMessage(content, t.ParentID, "forums", nil, nil)
   513  	_, err := topicStmts.edit.Exec(name, content, parsedContent, t.ID)
   514  	t.cacheRemove()
   515  	return err
   516  }
   517  
   518  func (t *Topic) SetPoll(pollID int) error {
   519  	_, e := topicStmts.setPoll.Exec(pollID, t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll
   520  	t.cacheRemove()
   521  	return e
   522  }
   523  
   524  func (t *Topic) RemovePoll() error {
   525  	_, e := topicStmts.removePoll.Exec(t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll
   526  	t.cacheRemove()
   527  	return e
   528  }
   529  
   530  // TODO: Have this go through the ReplyStore?
   531  // TODO: Return the rid?
   532  func (t *Topic) CreateActionReply(action, ip string, uid int) (err error) {
   533  	if Config.DisablePostIP {
   534  		ip = ""
   535  	}
   536  	res, err := topicStmts.createAction.Exec(t.ID, action, ip, uid)
   537  	if err != nil {
   538  		return err
   539  	}
   540  	_, err = topicStmts.addReplies.Exec(1, uid, t.ID)
   541  	if err != nil {
   542  		return err
   543  	}
   544  	lid, err := res.LastInsertId()
   545  	if err != nil {
   546  		return err
   547  	}
   548  	rid := int(lid)
   549  	_, err = topicStmts.updateLastReply.Exec(rid, rid, t.ID)
   550  	t.cacheRemove()
   551  	// ? - Update the last topic cache for the parent forum?
   552  	return err
   553  }
   554  
   555  func GetRidsForTopic(tid, offset int) (rids []int, e error) {
   556  	rows, e := topicStmts.getRids.Query(tid, offset, Config.ItemsPerPage)
   557  	if e != nil {
   558  		return nil, e
   559  	}
   560  	defer rows.Close()
   561  	var rid int
   562  	for rows.Next() {
   563  		if e := rows.Scan(&rid); e != nil {
   564  			return nil, e
   565  		}
   566  		rids = append(rids, rid)
   567  	}
   568  	return rids, rows.Err()
   569  }
   570  
   571  var aipost = ";&#xFE0E"
   572  var lockai = "&#x1F512" + aipost
   573  var unlockai = "&#x1F513"
   574  var stickai = "&#x1F4CC"
   575  var unstickai = "&#x1F4CC" + aipost
   576  
   577  func (ru *ReplyUser) Init(u *User) (group *Group, err error) {
   578  	ru.ContentLines = strings.Count(ru.Content, "\n")
   579  
   580  	postGroup, err := Groups.Get(ru.Group)
   581  	if err != nil {
   582  		return nil, err
   583  	}
   584  	if postGroup.IsMod {
   585  		ru.ClassName = Config.StaffCSS
   586  	}
   587  	ru.Tag = postGroup.Tag
   588  
   589  	if u.ID != ru.CreatedBy {
   590  		ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)
   591  		// TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this?
   592  		ru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar)
   593  	} else {
   594  		ru.UserLink = u.Link
   595  		ru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar
   596  	}
   597  
   598  	// We really shouldn't have inline HTML, we should do something about this...
   599  	if ru.ActionType != "" {
   600  		aarr := strings.Split(ru.ActionType, "-")
   601  		action := aarr[0]
   602  		switch action {
   603  		case "lock":
   604  			ru.ActionIcon = lockai
   605  		case "unlock":
   606  			ru.ActionIcon = unlockai
   607  		case "stick":
   608  			ru.ActionIcon = stickai
   609  		case "unstick":
   610  			ru.ActionIcon = unstickai
   611  		case "move":
   612  			if len(aarr) == 2 {
   613  				fid, _ := strconv.Atoi(aarr[1])
   614  				forum, err := Forums.Get(fid)
   615  				if err == nil {
   616  					ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)
   617  					return postGroup, nil
   618  				}
   619  			}
   620  		default:
   621  			// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?
   622  			ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType)
   623  			return postGroup, nil
   624  		}
   625  		ru.ActionType = p.GetTmplPhrasef("topic.action_topic_"+action, ru.UserLink, ru.CreatedByName)
   626  	}
   627  
   628  	return postGroup, nil
   629  }
   630  
   631  func (ru *ReplyUser) Init2() (group *Group, err error) {
   632  	//ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)
   633  	ru.ContentLines = strings.Count(ru.Content, "\n")
   634  
   635  	postGroup, err := Groups.Get(ru.Group)
   636  	if err != nil {
   637  		return postGroup, err
   638  	}
   639  	if postGroup.IsMod {
   640  		ru.ClassName = Config.StaffCSS
   641  	}
   642  	ru.Tag = postGroup.Tag
   643  
   644  	// We really shouldn't have inline HTML, we should do something about this...
   645  	if ru.ActionType != "" {
   646  		aarr := strings.Split(ru.ActionType, "-")
   647  		action := aarr[0]
   648  		switch action {
   649  		case "lock":
   650  			ru.ActionIcon = lockai
   651  		case "unlock":
   652  			ru.ActionIcon = unlockai
   653  		case "stick":
   654  			ru.ActionIcon = stickai
   655  		case "unstick":
   656  			ru.ActionIcon = unstickai
   657  		case "move":
   658  			if len(aarr) == 2 {
   659  				fid, _ := strconv.Atoi(aarr[1])
   660  				forum, err := Forums.Get(fid)
   661  				if err == nil {
   662  					ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)
   663  					return postGroup, nil
   664  				}
   665  			}
   666  		default:
   667  			// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?
   668  			ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType)
   669  			return postGroup, nil
   670  		}
   671  		ru.ActionType = p.GetTmplPhrasef("topic.action_topic_"+action, ru.UserLink, ru.CreatedByName)
   672  	}
   673  
   674  	return postGroup, nil
   675  }
   676  
   677  func (ru *ReplyUser) Init3(u *User, tu *TopicUser) (group *Group, err error) {
   678  	ru.ContentLines = strings.Count(ru.Content, "\n")
   679  
   680  	postGroup, err := Groups.Get(ru.Group)
   681  	if err != nil {
   682  		return postGroup, err
   683  	}
   684  	if postGroup.IsMod {
   685  		ru.ClassName = Config.StaffCSS
   686  	}
   687  	ru.Tag = postGroup.Tag
   688  
   689  	if u.ID == ru.CreatedBy {
   690  		ru.UserLink = u.Link
   691  		ru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar
   692  	} else if tu.CreatedBy == ru.CreatedBy {
   693  		ru.UserLink = tu.UserLink
   694  		ru.Avatar, ru.MicroAvatar = tu.Avatar, tu.MicroAvatar
   695  	} else {
   696  		ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)
   697  		// TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this?
   698  		ru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar)
   699  	}
   700  
   701  	// We really shouldn't have inline HTML, we should do something about this...
   702  	if ru.ActionType != "" {
   703  		aarr := strings.Split(ru.ActionType, "-")
   704  		action := aarr[0]
   705  		switch action {
   706  		case "lock":
   707  			ru.ActionIcon = lockai
   708  			action = "topic.action_topic_lock"
   709  		case "unlock":
   710  			ru.ActionIcon = unlockai
   711  			action = "topic.action_topic_unlock"
   712  		case "stick":
   713  			ru.ActionIcon = stickai
   714  			action = "topic.action_topic_stick"
   715  		case "unstick":
   716  			ru.ActionIcon = unstickai
   717  			action = "topic.action_topic_unstick"
   718  		case "move":
   719  			if len(aarr) == 2 {
   720  				fid, _ := strconv.Atoi(aarr[1])
   721  				forum, err := Forums.Get(fid)
   722  				if err == nil {
   723  					ru.ActionType = p.GetTmplPhrasef("topic.action_topic_move_dest", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)
   724  					return postGroup, nil
   725  				}
   726  			}
   727  		default:
   728  			// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?
   729  			ru.ActionType = p.GetTmplPhrasef("topic.action_topic_default", ru.ActionType)
   730  			return postGroup, nil
   731  		}
   732  		ru.ActionType = p.GetTmplPhrasef(action, ru.UserLink, ru.CreatedByName)
   733  	}
   734  
   735  	return postGroup, nil
   736  }
   737  
   738  // TODO: Factor TopicUser into a *Topic and *User, as this starting to become overly complicated x.x
   739  func (t *TopicUser) Replies(offset int /*pFrag int, */, user *User) (rlist []*ReplyUser /*, ogdesc string*/, externalHead bool, err error) {
   740  	var likedMap, attachMap map[int]int
   741  	var likedQueryList, attachQueryList []int
   742  
   743  	var rid int
   744  	if len(t.Rids) > 0 {
   745  		//log.Print("have rid")
   746  		rid = t.Rids[0]
   747  	}
   748  	re, err := Rstore.GetCache().Get(rid)
   749  	ucache := Users.GetCache()
   750  	var ruser *User
   751  	if ucache != nil {
   752  		//log.Print("ucache step")
   753  		if err == nil {
   754  			ruser = ucache.Getn(re.CreatedBy)
   755  		} else if t.PostCount == 2 {
   756  			ruser = ucache.Getn(t.LastReplyBy)
   757  		}
   758  	}
   759  
   760  	hTbl := GetHookTable()
   761  	rf := func(r *ReplyUser) (err error) {
   762  		//log.Printf("before r: %+v\n", r)
   763  		postGroup, err := r.Init3(user, t)
   764  		if err != nil {
   765  			return err
   766  		}
   767  		//log.Printf("after r: %+v\n", r)
   768  
   769  		var parseSettings *ParseSettings
   770  		if (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {
   771  			parseSettings = DefaultParseSettings.CopyPtr()
   772  			parseSettings.NoEmbed = true
   773  		} else {
   774  			parseSettings = user.ParseSettings
   775  		}
   776  		/*if user.ParseSettings == nil {
   777  			parseSettings = DefaultParseSettings.CopyPtr()
   778  			parseSettings.NoEmbed = Config.NoEmbed || !postGroup.Perms.AutoEmbed
   779  			parseSettings.NoLink = !postGroup.Perms.AutoLink
   780  		} else {
   781  			parseSettings = user.ParseSettings
   782  		}*/
   783  
   784  		var eh bool
   785  		r.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, "forums", parseSettings, user)
   786  		if eh {
   787  			externalHead = true
   788  		}
   789  		// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.
   790  		if r.ContentHtml == r.Content {
   791  			r.ContentHtml = r.Content
   792  		}
   793  		r.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID
   794  
   795  		// TODO: This doesn't work properly so pick the first one instead?
   796  		/*if r.ID == pFrag {
   797  			ogdesc = r.Content
   798  			if len(ogdesc) > 200 {
   799  				ogdesc = ogdesc[:197] + "..."
   800  			}
   801  		}*/
   802  
   803  		return nil
   804  	}
   805  
   806  	rf3 := func(r *ReplyUser) error {
   807  		//log.Printf("before r: %+v\n", r)
   808  		postGroup, err := r.Init2()
   809  		if err != nil {
   810  			return err
   811  		}
   812  
   813  		var parseSettings *ParseSettings
   814  		if (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {
   815  			parseSettings = DefaultParseSettings.CopyPtr()
   816  			parseSettings.NoEmbed = true
   817  		} else {
   818  			parseSettings = user.ParseSettings
   819  		}
   820  
   821  		var eh bool
   822  		r.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, "forums", parseSettings, user)
   823  		if eh {
   824  			externalHead = true
   825  		}
   826  		// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.
   827  		if r.ContentHtml == r.Content {
   828  			r.ContentHtml = r.Content
   829  		}
   830  		r.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID
   831  
   832  		return nil
   833  	}
   834  
   835  	// TODO: Factor the user fields out and embed a user struct instead
   836  	if err == nil && ruser != nil {
   837  		//log.Print("reply cached serve")
   838  		r := &ReplyUser{ /*ClassName: "", */ Reply: *re, CreatedByName: ruser.Name, UserLink: ruser.Link, Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar /*URLPrefix: ruser.URLPrefix, URLName: ruser.URLName, */, Group: ruser.Group, Level: ruser.Level, Tag: ruser.Tag}
   839  		if err = rf3(r); err != nil {
   840  			return nil, externalHead, err
   841  		}
   842  
   843  		if r.LikeCount > 0 && user.Liked > 0 {
   844  			likedMap = map[int]int{r.ID: 0}
   845  			likedQueryList = []int{r.ID}
   846  		}
   847  		if user.Perms.EditReply && r.AttachCount > 0 {
   848  			if likedMap == nil {
   849  				attachMap = map[int]int{r.ID: 0}
   850  				attachQueryList = []int{r.ID}
   851  			} else {
   852  				attachMap = likedMap
   853  				attachQueryList = likedQueryList
   854  			}
   855  		}
   856  
   857  		H_topic_reply_row_assign_hook(hTbl, r)
   858  		// TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis?
   859  		rlist = []*ReplyUser{r}
   860  		//log.Printf("r: %d-%d", r.ID, len(rlist)-1)
   861  	} else {
   862  		//log.Print("reply query serve")
   863  		ap1 := func(r *ReplyUser) {
   864  			H_topic_reply_row_assign_hook(hTbl, r)
   865  			// TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis?
   866  			rlist = append(rlist, r)
   867  			//log.Printf("r: %d-%d", r.ID, len(rlist)-1)
   868  		}
   869  		rf2 := func(r *ReplyUser) {
   870  			if r.LikeCount > 0 && user.Liked > 0 {
   871  				if likedMap == nil {
   872  					likedMap = map[int]int{r.ID: len(rlist)}
   873  					likedQueryList = []int{r.ID}
   874  				} else {
   875  					likedMap[r.ID] = len(rlist)
   876  					likedQueryList = append(likedQueryList, r.ID)
   877  				}
   878  			}
   879  			if user.Perms.EditReply && r.AttachCount > 0 {
   880  				if attachMap == nil {
   881  					attachMap = map[int]int{r.ID: len(rlist)}
   882  					attachQueryList = []int{r.ID}
   883  				} else {
   884  					attachMap[r.ID] = len(rlist)
   885  					attachQueryList = append(attachQueryList, r.ID)
   886  				}
   887  			}
   888  		}
   889  		if !user.Perms.ViewIPs && ruser != nil {
   890  			rows, e := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage)
   891  			if err != nil {
   892  				return nil, externalHead, e
   893  			}
   894  			defer rows.Close()
   895  			for rows.Next() {
   896  				r := &ReplyUser{Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar, UserLink: ruser.Link, CreatedByName: ruser.Name, Group: ruser.Group, Level: ruser.Level}
   897  				e := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.LikeCount, &r.AttachCount, &r.ActionType)
   898  				if e != nil {
   899  					return nil, externalHead, e
   900  				}
   901  				if e = rf3(r); e != nil {
   902  					return nil, externalHead, e
   903  				}
   904  				rf2(r)
   905  				ap1(r)
   906  			}
   907  			if e = rows.Err(); e != nil {
   908  				return nil, externalHead, e
   909  			}
   910  		} else if user.Perms.ViewIPs {
   911  			rows, err := topicStmts.getReplies.Query(t.ID, offset, Config.ItemsPerPage)
   912  			if err != nil {
   913  				return nil, externalHead, err
   914  			}
   915  			defer rows.Close()
   916  			for rows.Next() {
   917  				r := &ReplyUser{}
   918  				err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType)
   919  				if err != nil {
   920  					return nil, externalHead, err
   921  				}
   922  				if err = rf(r); err != nil {
   923  					return nil, externalHead, err
   924  				}
   925  				rf2(r)
   926  				ap1(r)
   927  			}
   928  			if err = rows.Err(); err != nil {
   929  				return nil, externalHead, err
   930  			}
   931  		} else if t.PostCount >= 20 {
   932  			//log.Print("t.PostCount >= 20")
   933  			rows, err := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage)
   934  			if err != nil {
   935  				return nil, externalHead, err
   936  			}
   937  			defer rows.Close()
   938  			reqUserList := make(map[int]bool)
   939  			for rows.Next() {
   940  				r := &ReplyUser{}
   941  				err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy /*&r.URLPrefix, &r.URLName,*/, &r.LikeCount, &r.AttachCount, &r.ActionType)
   942  				if err != nil {
   943  					return nil, externalHead, err
   944  				}
   945  				if r.CreatedBy != t.CreatedBy && r.CreatedBy != user.ID {
   946  					reqUserList[r.CreatedBy] = true
   947  				}
   948  				ap1(r)
   949  			}
   950  			if err = rows.Err(); err != nil {
   951  				return nil, externalHead, err
   952  			}
   953  
   954  			if len(reqUserList) == 1 {
   955  				//log.Print("len(reqUserList) == 1: ", len(reqUserList) == 1)
   956  				var uitem *User
   957  				for uid, _ := range reqUserList {
   958  					uitem, err = Users.Get(uid)
   959  					if err != nil {
   960  						return nil, externalHead, nil // TODO: Implement this!
   961  					}
   962  				}
   963  				for _, r := range rlist {
   964  					if r.CreatedBy == t.CreatedBy {
   965  						r.CreatedByName = t.CreatedByName
   966  						r.Avatar = t.Avatar
   967  						r.MicroAvatar = t.MicroAvatar
   968  						r.Group = t.Group
   969  						r.Level = t.Level
   970  					} else {
   971  						var u *User
   972  						if r.CreatedBy == user.ID {
   973  							u = user
   974  						} else {
   975  							u = uitem
   976  						}
   977  						r.CreatedByName = u.Name
   978  						r.Avatar = u.Avatar
   979  						r.MicroAvatar = u.MicroAvatar
   980  						r.Group = u.Group
   981  						r.Level = u.Level
   982  					}
   983  					if err = rf(r); err != nil {
   984  						return nil, externalHead, err
   985  					}
   986  					rf2(r)
   987  				}
   988  			} else {
   989  				//log.Print("len(reqUserList) != 1: ", len(reqUserList) != 1)
   990  				var userList map[int]*User
   991  				if len(reqUserList) > 0 {
   992  					//log.Print("len(reqUserList) > 0: ", len(reqUserList) > 0)
   993  					// Convert the user ID map to a slice, then bulk load the users
   994  					idSlice := make([]int, len(reqUserList))
   995  					var i int
   996  					for userID := range reqUserList {
   997  						idSlice[i] = userID
   998  						i++
   999  					}
  1000  					userList, err = Users.BulkGetMap(idSlice)
  1001  					if err != nil {
  1002  						return nil, externalHead, nil // TODO: Implement this!
  1003  					}
  1004  				}
  1005  
  1006  				for _, r := range rlist {
  1007  					if r.CreatedBy == t.CreatedBy {
  1008  						r.CreatedByName = t.CreatedByName
  1009  						r.Avatar = t.Avatar
  1010  						r.MicroAvatar = t.MicroAvatar
  1011  						r.Group = t.Group
  1012  						r.Level = t.Level
  1013  					} else {
  1014  						var u *User
  1015  						if r.CreatedBy == user.ID {
  1016  							u = user
  1017  						} else {
  1018  							u = userList[r.CreatedBy]
  1019  						}
  1020  						r.CreatedByName = u.Name
  1021  						r.Avatar = u.Avatar
  1022  						r.MicroAvatar = u.MicroAvatar
  1023  						r.Group = u.Group
  1024  						r.Level = u.Level
  1025  					}
  1026  					if err = rf(r); err != nil {
  1027  						return nil, externalHead, err
  1028  					}
  1029  					rf2(r)
  1030  				}
  1031  			}
  1032  		} else {
  1033  			//log.Print("reply fallback")
  1034  			rows, err := topicStmts.getReplies2.Query(t.ID, offset, Config.ItemsPerPage)
  1035  			if err != nil {
  1036  				return nil, externalHead, err
  1037  			}
  1038  			defer rows.Close()
  1039  			for rows.Next() {
  1040  				r := &ReplyUser{}
  1041  				err := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.LikeCount, &r.AttachCount, &r.ActionType)
  1042  				if err != nil {
  1043  					return nil, externalHead, err
  1044  				}
  1045  				if err = rf(r); err != nil {
  1046  					return nil, externalHead, err
  1047  				}
  1048  				rf2(r)
  1049  				ap1(r)
  1050  			}
  1051  			if err = rows.Err(); err != nil {
  1052  				return nil, externalHead, err
  1053  			}
  1054  		}
  1055  	}
  1056  
  1057  	// TODO: Add a config setting to disable the liked query for a burst of extra speed
  1058  	if user.Liked > 0 && len(likedQueryList) > 0 /*&& user.LastLiked <= time.Now()*/ {
  1059  		e := Likes.BulkExistsFunc(likedQueryList, user.ID, "replies", func(eid int) error {
  1060  			rlist[likedMap[eid]].Liked = true
  1061  			return nil
  1062  		})
  1063  		if e != nil {
  1064  			return nil, externalHead, e
  1065  		}
  1066  	}
  1067  
  1068  	if user.Perms.EditReply && len(attachQueryList) > 0 {
  1069  		//log.Printf("attachQueryList: %+v\n", attachQueryList)
  1070  		amap, err := Attachments.BulkMiniGetList("replies", attachQueryList)
  1071  		if err != nil && err != sql.ErrNoRows {
  1072  			return nil, externalHead, err
  1073  		}
  1074  		//log.Printf("amap: %+v\n", amap)
  1075  		//log.Printf("attachMap: %+v\n", attachMap)
  1076  		for id, attach := range amap {
  1077  			//log.Print("id:", id)
  1078  			rlist[attachMap[id]].Attachments = attach
  1079  			/*for _, a := range attach {
  1080  				log.Printf("a: %+v\n", a)
  1081  			}*/
  1082  		}
  1083  	}
  1084  
  1085  	//hTbl.VhookNoRet("topic_reply_end", &rlist)
  1086  
  1087  	return rlist, externalHead, nil
  1088  }
  1089  
  1090  // TODO: Test this
  1091  func (t *Topic) Author() (*User, error) {
  1092  	return Users.Get(t.CreatedBy)
  1093  }
  1094  
  1095  func (t *Topic) GetID() int {
  1096  	return t.ID
  1097  }
  1098  func (t *Topic) GetTable() string {
  1099  	return "topics"
  1100  }
  1101  
  1102  // Copy gives you a non-pointer concurrency safe copy of the topic
  1103  func (t *Topic) Copy() Topic {
  1104  	return *t
  1105  }
  1106  
  1107  // TODO: Load LastReplyAt and LastReplyID?
  1108  func TopicByReplyID(rid int) (*Topic, error) {
  1109  	t := Topic{ID: 0}
  1110  	err := topicStmts.getByReplyID.QueryRow(rid).Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.Poll, &t.Data)
  1111  	t.Link = BuildTopicURL(NameToSlug(t.Title), t.ID)
  1112  	return &t, err
  1113  }
  1114  
  1115  // TODO: Refactor the caller to take a Topic and a User rather than a combined TopicUser
  1116  // TODO: Load LastReplyAt everywhere in here?
  1117  func GetTopicUser(user *User, tid int) (tu TopicUser, err error) {
  1118  	tcache := Topics.GetCache()
  1119  	ucache := Users.GetCache()
  1120  	if tcache != nil && ucache != nil {
  1121  		topic, err := tcache.Get(tid)
  1122  		if err == nil {
  1123  			if topic.CreatedBy != user.ID {
  1124  				user, err = Users.Get(topic.CreatedBy)
  1125  				if err != nil {
  1126  					return TopicUser{ID: tid}, err
  1127  				}
  1128  			}
  1129  			// We might be better off just passing separate topic and user structs to the caller?
  1130  			return copyTopicToTopicUser(topic, user), nil
  1131  		} else if ucache.Length() < ucache.GetCapacity() {
  1132  			topic, err = Topics.Get(tid)
  1133  			if err != nil {
  1134  				return TopicUser{ID: tid}, err
  1135  			}
  1136  			if topic.CreatedBy != user.ID {
  1137  				user, err = Users.Get(topic.CreatedBy)
  1138  				if err != nil {
  1139  					return TopicUser{ID: tid}, err
  1140  				}
  1141  			}
  1142  			return copyTopicToTopicUser(topic, user), nil
  1143  		}
  1144  	}
  1145  
  1146  	tu = TopicUser{ID: tid}
  1147  	// TODO: This misses some important bits...
  1148  	err = topicStmts.getTopicUser.QueryRow(tid).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.LastReplyAt, &tu.LastReplyBy, &tu.LastReplyID, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.ViewCount, &tu.PostCount, &tu.LikeCount, &tu.AttachCount, &tu.Poll, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level)
  1149  	tu.Avatar, tu.MicroAvatar = BuildAvatar(tu.CreatedBy, tu.Avatar)
  1150  	tu.Link = BuildTopicURL(NameToSlug(tu.Title), tu.ID)
  1151  	tu.UserLink = BuildProfileURL(NameToSlug(tu.CreatedByName), tu.CreatedBy)
  1152  	tu.Tag = Groups.DirtyGet(tu.Group).Tag
  1153  
  1154  	if tcache != nil {
  1155  		// TODO: weekly views
  1156  		theTopic := Topic{ID: tu.ID, Link: tu.Link, Title: tu.Title, Content: tu.Content, CreatedBy: tu.CreatedBy, IsClosed: tu.IsClosed, Sticky: tu.Sticky, CreatedAt: tu.CreatedAt, LastReplyAt: tu.LastReplyAt, LastReplyID: tu.LastReplyID, ParentID: tu.ParentID, IP: tu.IP, ViewCount: tu.ViewCount, PostCount: tu.PostCount, LikeCount: tu.LikeCount, AttachCount: tu.AttachCount, Poll: tu.Poll}
  1157  		//log.Printf("theTopic: %+v\n", theTopic)
  1158  		_ = tcache.Set(&theTopic)
  1159  	}
  1160  	return tu, err
  1161  }
  1162  
  1163  func copyTopicToTopicUser(t *Topic, u *User) (tu TopicUser) {
  1164  	tu.UserLink = u.Link
  1165  	tu.CreatedByName = u.Name
  1166  	tu.Group = u.Group
  1167  	tu.Avatar = u.Avatar
  1168  	tu.MicroAvatar = u.MicroAvatar
  1169  	//tu.URLPrefix = u.URLPrefix
  1170  	//tu.URLName = u.URLName
  1171  	tu.Level = u.Level
  1172  
  1173  	tu.ID = t.ID
  1174  	tu.Link = t.Link
  1175  	tu.Title = t.Title
  1176  	tu.Content = t.Content
  1177  	tu.CreatedBy = t.CreatedBy
  1178  	tu.IsClosed = t.IsClosed
  1179  	tu.Sticky = t.Sticky
  1180  	tu.CreatedAt = t.CreatedAt
  1181  	tu.LastReplyAt = t.LastReplyAt
  1182  	tu.LastReplyBy = t.LastReplyBy
  1183  	tu.ParentID = t.ParentID
  1184  	tu.IP = t.IP
  1185  	tu.ViewCount = t.ViewCount
  1186  	tu.PostCount = t.PostCount
  1187  	tu.LikeCount = t.LikeCount
  1188  	tu.AttachCount = t.AttachCount
  1189  	tu.Poll = t.Poll
  1190  	tu.Data = t.Data
  1191  	tu.Rids = t.Rids
  1192  
  1193  	return tu
  1194  }
  1195  
  1196  // For use in tests and for generating blank topics for forums which don't have a last poster
  1197  func BlankTopic() *Topic {
  1198  	return new(Topic)
  1199  }
  1200  
  1201  func BuildTopicURL(slug string, tid int) string {
  1202  	if slug == "" || !Config.BuildSlugs {
  1203  		return "/topic/" + strconv.Itoa(tid)
  1204  	}
  1205  	return "/topic/" + slug + "." + strconv.Itoa(tid)
  1206  }
  1207  
  1208  func BuildTopicURLSb(sb *strings.Builder, slug string, tid int) {
  1209  	if slug == "" || !Config.BuildSlugs {
  1210  		sb.Grow(7 + 2)
  1211  		sb.WriteString("/topic/")
  1212  		sb.WriteString(strconv.Itoa(tid))
  1213  		return
  1214  	}
  1215  	sb.Grow(7 + 3 + len(slug))
  1216  	sb.WriteString("/topic/")
  1217  	sb.WriteString(slug)
  1218  	sb.WriteRune('.')
  1219  	sb.WriteString(strconv.Itoa(tid))
  1220  }
  1221  
  1222  // I don't care if it isn't used,, it will likely be in the future. Nolint.
  1223  // nolint
  1224  func getTopicURLPrefix() string {
  1225  	return "/topic/"
  1226  }