package ttbp import ( "bufio" "bytes" "encoding/json" "fmt" "os" "os/user" "path" "sort" "strings" "time" "git.tilde.town/nbsp/neofeels/config" "github.com/yuin/goldmark" ) var ( PathVar = "/var/global/ttbp" PathVarWWW = path.Join(PathVar, "www") PathLive = "https://tilde.town/~" PathUserFile = path.Join(PathVar, "users.txt") PathGraff = path.Join(PathVar, "graffiti") PathWall = path.Join(PathGraff, "wall.txt") PathWallLock = path.Join(PathGraff, ".lock") PathUser = os.Getenv("HOME") PathUserFeels = path.Join(PathUser, ".ttbp") PathUserHTML = path.Join(PathUser, "public_html") PathUserConfig = path.Join(PathUserFeels, "config") PathUserEntries = path.Join(PathUserFeels, "entries") PathUserBuried = path.Join(PathUserFeels, "buried") PathUserBackups = path.Join(PathUserFeels, "backups") PathUserWWW = path.Join(PathUserFeels, "www") PathUserRc = path.Join(PathUserConfig, "ttbprc") PathUserNopub = path.Join(PathUserConfig, "nopub") PathUserSubs = path.Join(PathUserConfig, "subs") ) type User struct { Name string Publishing bool PublishDir string LastPublished time.Time } // GetUsers gets all users with a valid ttbp config, with their name, publishing // settings, and date of last publish, if any. func GetUsers() (users []User) { userDirs, _ := os.ReadDir("/home") for _, user := range userDirs { if !user.IsDir() { continue } if file, err := os.Open(path.Join("/home", user.Name(), ".ttbp/config/ttbprc")); err == nil { defer file.Close() // get user config var config config.Config decoder := json.NewDecoder(file) err = decoder.Decode(&config) if err != nil { continue } // get last published file entries, err := os.ReadDir(path.Join("/home", user.Name(), ".ttbp/entries")) if err != nil { continue } var lastPublished time.Time = *new(time.Time) if len(entries) > 0 { info, err := entries[len(entries)-1].Info() if err != nil { continue } lastPublished = info.ModTime() } users = append(users, User{ Name: user.Name(), Publishing: config.Publishing, PublishDir: config.PublishDir, LastPublished: lastPublished, }) } } return users } // SortUsersByRecent sorts users, putting the users with the most recent // published feels at the beginning. func SortUsersByRecent(users []User) []User { sort.Slice(users, func(i, j int) bool { return users[i].LastPublished.After(users[j].LastPublished) }) return users } type Post struct { Date time.Time LastEdited time.Time Words int Author string Nopub bool } func GetPostsForUser(user string) (posts []Post) { postFiles, err := os.ReadDir(path.Join("/home", user, ".ttbp/entries")) if err != nil { return } nopubFile, err := os.OpenFile(PathUserNopub, os.O_RDONLY|os.O_CREATE, 0644) if err != nil { return } defer nopubFile.Close() var nopubs []string nopubScanner := bufio.NewScanner(nopubFile) for nopubScanner.Scan() { nopubs = append(nopubs, nopubScanner.Text()) } for _, post := range postFiles { // retrieve date of file // assume file ends in .txt fileDate, err := time.Parse("20060102.txt", post.Name()) if err != nil { continue } if file, err := os.Open(path.Join("/home", user, ".ttbp/entries", post.Name())); err == nil { defer file.Close() // get number of words in file scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanWords) count := 0 for scanner.Scan() { count++ } // get modtime of file stat, err := file.Stat() if err != nil { continue } // see if file is in nopub nopub := false for _, name := range nopubs { if name == post.Name() { nopub = true break } } posts = append([]Post{Post{ Author: user, Date: fileDate, LastEdited: stat.ModTime(), Words: count, Nopub: nopub, }}, posts...) } } return } // SortUsersByRecent sorts posts, putting the posts that were most recently // edited first in the list. func SortPostsByRecent(posts []Post) []Post { sort.Slice(posts, func(i, j int) bool { return posts[i].LastEdited.After(posts[j].LastEdited) }) return posts } type Subscriptions struct { users []string } func GetSubscriptions() *Subscriptions { file, err := os.OpenFile(PathUserSubs, os.O_RDONLY|os.O_CREATE, 0600) if err != nil { return &Subscriptions{} } defer file.Close() var users []string scanner := bufio.NewScanner(file) for scanner.Scan() { users = append(users, scanner.Text()) } return &Subscriptions{ users: users, } } func (subscriptions *Subscriptions) IsSubscribed(user User) bool { for _, sub := range subscriptions.users { if sub == user.Name { return true } } return false } func (subscriptions *Subscriptions) Subscribe(user User) { subscriptions.users = append(subscriptions.users, user.Name) } func (subscriptions *Subscriptions) Unsubscribe(user User) { for i, sub := range subscriptions.users { if sub == user.Name { subscriptions.users = append(subscriptions.users[:i], subscriptions.users[i+1:]...) return } } } func (subscriptions *Subscriptions) Write() { file, err := os.Create(PathUserSubs) if err != nil { return } defer file.Close() writer := bufio.NewWriter(file) for _, line := range subscriptions.users { _, err := writer.WriteString(line + "\n") if err != nil { return } } writer.Flush() } func NewNopub(t time.Time) { cfg, err := config.Read() if err != nil || (!cfg.Nopub && cfg.Publishing) { return } dateString := t.Format("20060102.txt") file, err := os.OpenFile(PathUserNopub, os.O_RDWR|os.O_CREATE, 0600) if err != nil { return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { if scanner.Text() == dateString { return } } writer := bufio.NewWriter(file) writer.WriteString(dateString) writer.Flush() } func ToggleNopub(t time.Time) { dateString := t.Format("20060102.txt") nopubs, err := os.ReadFile(PathUserNopub) if err != nil { return } lines := strings.Split(strings.TrimSpace(string(nopubs)), "\n") newLines := []string{} exists := false for _, line := range lines { if line == dateString { exists = true } else { newLines = append(newLines, line) } } if !exists { newLines = append(newLines, dateString) } file, err := os.Create(PathUserNopub) if err != nil { return } defer file.Close() writer := bufio.NewWriter(file) for _, line := range newLines { if line == "" { continue } _, err := writer.WriteString(line + "\n") if err != nil { return } } writer.Flush() } func Publish() { cfg, err := config.Read() if err != nil { return // TODO: expose this error to the user } if cfg.Publishing { if _, err := os.Stat(PathUserWWW); os.IsNotExist(err) { os.MkdirAll(PathUserWWW, 0700) os.Symlink(path.Join(PathUserConfig, "style.css"), path.Join(PathUserWWW, "style.css")) } file, err := os.Create(path.Join(PathUserWWW, "index.html")) defer file.Close() // load header and footer header, err := os.ReadFile(path.Join(PathUserConfig, "header.txt")) footer, err := os.ReadFile(path.Join(PathUserConfig, "footer.txt")) if err != nil { return } writer := bufio.NewWriter(file) writer.WriteString(fmt.Sprintf("\n", time.Now().Format(time.DateTime))) writer.WriteString(string(header) + "\n") user, _ := user.Current() for _, post := range GetPostsForUser(user.Username) { if !post.Nopub && post.Words > 0 { writePage(post, header, footer) writer.WriteString(writeEntry(post) + "\n") } } writer.WriteString(string(footer)) writer.Flush() } // TODO: gopher } func Unpublish() { cfg, err := config.Read() if err != nil { return // TODO: expose } if cfg.Publishing { os.RemoveAll(PathUserWWW) os.RemoveAll(path.Join(PathUserHTML, cfg.PublishDir)) } } func writePage(post Post, header, footer []byte) { dateString := post.Date.Format("20060102.html") file, err := os.Create(path.Join(PathUserWWW, dateString)) if err != nil { return } writer := bufio.NewWriter(file) writer.WriteString(fmt.Sprintf("<-- generated by neofeels on %s — https://tilde.town/~nbsp/neofeels -->\n", time.Now().Format(time.DateTime))) writer.WriteString(string(header) + "\n") writer.WriteString(writeEntry(post) + "\n") writer.WriteString(string(footer)) writer.Flush() } func writeEntry(post Post) string { dateString := post.Date.Format("20060102") file, err := os.ReadFile(path.Join(PathUserEntries, dateString+".txt")) if err != nil { return "" } var buf bytes.Buffer goldmark.Convert(file, &buf) return fmt.Sprintf( `



%d %s %d
%s
`, dateString, dateString, post.Date.Day(), strings.ToLower(post.Date.Month().String()), post.Date.Year(), buf.String(), dateString, ) } func GraffitiFree() bool { _, err := os.Stat(PathWallLock) return os.IsNotExist(err) }