package forum import ( "fmt" "strings" "time" "dk/internal/database" "dk/internal/helpers/scanner" "zombiezen.com/go/sqlite" ) // Forum represents a forum post or thread in the database type Forum struct { ID int `db:"id" json:"id"` Posted int64 `db:"posted" json:"posted"` LastPost int64 `db:"last_post" json:"last_post"` Author int `db:"author" json:"author"` Parent int `db:"parent" json:"parent"` Replies int `db:"replies" json:"replies"` Title string `db:"title" json:"title"` Content string `db:"content" json:"content"` } // New creates a new Forum with sensible defaults func New() *Forum { now := time.Now().Unix() return &Forum{ Posted: now, LastPost: now, Author: 0, Parent: 0, // Default to thread (not reply) Replies: 0, Title: "", Content: "", } } var forumScanner = scanner.New[Forum]() // forumColumns returns the column list for forum queries func forumColumns() string { return forumScanner.Columns() } // scanForum populates a Forum struct using the fast scanner func scanForum(stmt *sqlite.Stmt) *Forum { forum := &Forum{} forumScanner.Scan(stmt, forum) return forum } // Find retrieves a forum post by ID func Find(id int) (*Forum, error) { var forum *Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE id = ?` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum = scanForum(stmt) return nil }, id) if err != nil { return nil, fmt.Errorf("failed to find forum post: %w", err) } if forum == nil { return nil, fmt.Errorf("forum post with ID %d not found", id) } return forum, nil } // All retrieves all forum posts ordered by last post time (most recent first) func All() ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum ORDER BY last_post DESC, id DESC` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }) if err != nil { return nil, fmt.Errorf("failed to retrieve all forum posts: %w", err) } return forums, nil } // Threads retrieves all top-level forum threads (parent = 0) func Threads() ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }) if err != nil { return nil, fmt.Errorf("failed to retrieve forum threads: %w", err) } return forums, nil } // ByParent retrieves all replies to a specific thread/post func ByParent(parentID int) ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }, parentID) if err != nil { return nil, fmt.Errorf("failed to retrieve forum replies: %w", err) } return forums, nil } // ByAuthor retrieves forum posts by a specific author func ByAuthor(authorID int) ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE author = ? ORDER BY posted DESC, id DESC` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }, authorID) if err != nil { return nil, fmt.Errorf("failed to retrieve forum posts by author: %w", err) } return forums, nil } // Recent retrieves the most recent forum activity (limited by count) func Recent(limit int) ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum ORDER BY last_post DESC, id DESC LIMIT ?` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }, limit) if err != nil { return nil, fmt.Errorf("failed to retrieve recent forum posts: %w", err) } return forums, nil } // Search retrieves forum posts containing the search term in title or content func Search(term string) ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) ORDER BY last_post DESC, id DESC` searchTerm := "%" + term + "%" err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }, searchTerm, searchTerm) if err != nil { return nil, fmt.Errorf("failed to search forum posts: %w", err) } return forums, nil } // Since retrieves forum posts with activity since a specific timestamp func Since(since int64) ([]*Forum, error) { var forums []*Forum query := `SELECT ` + forumColumns() + ` FROM forum WHERE last_post >= ? ORDER BY last_post DESC, id DESC` err := database.Query(query, func(stmt *sqlite.Stmt) error { forum := scanForum(stmt) forums = append(forums, forum) return nil }, since) if err != nil { return nil, fmt.Errorf("failed to retrieve forum posts since timestamp: %w", err) } return forums, nil } // Save updates an existing forum post in the database func (f *Forum) Save() error { if f.ID == 0 { return fmt.Errorf("cannot save forum post without ID") } query := `UPDATE forum SET posted = ?, last_post = ?, author = ?, parent = ?, replies = ?, title = ?, content = ? WHERE id = ?` return database.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.ID) } // Insert saves a new forum post to the database and sets the ID func (f *Forum) Insert() error { if f.ID != 0 { return fmt.Errorf("forum post already has ID %d, use Save() to update", f.ID) } // Use a transaction to ensure we can get the ID err := database.Transaction(func(tx *database.Tx) error { query := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) VALUES (?, ?, ?, ?, ?, ?, ?)` if err := tx.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content); err != nil { return fmt.Errorf("failed to insert forum post: %w", err) } // Get the last insert ID var id int err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error { id = stmt.ColumnInt(0) return nil }) if err != nil { return fmt.Errorf("failed to get insert ID: %w", err) } f.ID = id return nil }) return err } // Delete removes the forum post from the database func (f *Forum) Delete() error { if f.ID == 0 { return fmt.Errorf("cannot delete forum post without ID") } return database.Exec("DELETE FROM forum WHERE id = ?", f.ID) } // PostedTime returns the posted timestamp as a time.Time func (f *Forum) PostedTime() time.Time { return time.Unix(f.Posted, 0) } // LastPostTime returns the last post timestamp as a time.Time func (f *Forum) LastPostTime() time.Time { return time.Unix(f.LastPost, 0) } // SetPostedTime sets the posted timestamp from a time.Time func (f *Forum) SetPostedTime(t time.Time) { f.Posted = t.Unix() } // SetLastPostTime sets the last post timestamp from a time.Time func (f *Forum) SetLastPostTime(t time.Time) { f.LastPost = t.Unix() } // IsThread returns true if this is a top-level thread (parent = 0) func (f *Forum) IsThread() bool { return f.Parent == 0 } // IsReply returns true if this is a reply to another post (parent > 0) func (f *Forum) IsReply() bool { return f.Parent > 0 } // HasReplies returns true if this post has replies func (f *Forum) HasReplies() bool { return f.Replies > 0 } // IsRecentActivity returns true if there has been activity within the last 24 hours func (f *Forum) IsRecentActivity() bool { return time.Since(f.LastPostTime()) < 24*time.Hour } // ActivityAge returns how long ago the last activity occurred func (f *Forum) ActivityAge() time.Duration { return time.Since(f.LastPostTime()) } // PostAge returns how long ago the post was originally made func (f *Forum) PostAge() time.Duration { return time.Since(f.PostedTime()) } // IsAuthor returns true if the given user ID is the author of this post func (f *Forum) IsAuthor(userID int) bool { return f.Author == userID } // Preview returns a truncated version of the content for previews func (f *Forum) Preview(maxLength int) string { if len(f.Content) <= maxLength { return f.Content } if maxLength < 3 { return f.Content[:maxLength] } return f.Content[:maxLength-3] + "..." } // WordCount returns the number of words in the content func (f *Forum) WordCount() int { if f.Content == "" { return 0 } // Simple word count by splitting on whitespace words := 0 inWord := false for _, char := range f.Content { if char == ' ' || char == '\t' || char == '\n' || char == '\r' { if inWord { words++ inWord = false } } else { inWord = true } } if inWord { words++ } return words } // Length returns the character length of the content func (f *Forum) Length() int { return len(f.Content) } // Contains returns true if the title or content contains the given term (case-insensitive) func (f *Forum) Contains(term string) bool { lowerTerm := strings.ToLower(term) return strings.Contains(strings.ToLower(f.Title), lowerTerm) || strings.Contains(strings.ToLower(f.Content), lowerTerm) } // UpdateLastPost updates the last_post timestamp to current time func (f *Forum) UpdateLastPost() { f.LastPost = time.Now().Unix() } // IncrementReplies increments the reply count func (f *Forum) IncrementReplies() { f.Replies++ } // DecrementReplies decrements the reply count (minimum 0) func (f *Forum) DecrementReplies() { if f.Replies > 0 { f.Replies-- } } // GetReplies retrieves all direct replies to this post func (f *Forum) GetReplies() ([]*Forum, error) { return ByParent(f.ID) } // GetThread retrieves the parent thread (if this is a reply) or returns self (if this is a thread) func (f *Forum) GetThread() (*Forum, error) { if f.IsThread() { return f, nil } return Find(f.Parent) } // ToMap converts the forum post to a map for efficient template rendering func (f *Forum) ToMap() map[string]any { return map[string]any{ "ID": f.ID, "Posted": f.Posted, "LastPost": f.LastPost, "Author": f.Author, "Parent": f.Parent, "Replies": f.Replies, "Title": f.Title, "Content": f.Content, // Computed values "PostedTime": f.PostedTime(), "LastPostTime": f.LastPostTime(), "IsThread": f.IsThread(), "IsReply": f.IsReply(), "HasReplies": f.HasReplies(), "IsRecentActivity": f.IsRecentActivity(), "ActivityAge": f.ActivityAge(), "PostAge": f.PostAge(), "WordCount": f.WordCount(), "Length": f.Length(), } }