578 lines
15 KiB
Go
578 lines
15 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"flag"
|
||
"os/exec"
|
||
"runtime"
|
||
"context"
|
||
"path/filepath"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/joho/godotenv"
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/mattn/go-runewidth"
|
||
)
|
||
|
||
var SHORT_TWEET_SIZE int = 180
|
||
|
||
func newApp() (*App, error) {
|
||
screen, err := tcell.NewScreen()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create screen: %v", err)
|
||
}
|
||
|
||
if err := screen.Init(); err != nil {
|
||
return nil, fmt.Errorf("failed to initialize screen: %v", err)
|
||
}
|
||
|
||
return &App{
|
||
screen: screen,
|
||
selectedIdx: 0,
|
||
currentPage: 0,
|
||
}, nil
|
||
}
|
||
|
||
func (a *App) loadTweets(accountsList string) ([]Tweet, error) {
|
||
if err := godotenv.Load(".env"); err != nil {
|
||
return []Tweet{}, fmt.Errorf("error loading .env file: %v", err)
|
||
}
|
||
|
||
url := os.Getenv("DATABASE_POOL_URL")
|
||
if url == "" {
|
||
return []Tweet{}, fmt.Errorf("DATABASE_POOL_URL environment variable not set")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
conn, err := pgx.Connect(ctx, url)
|
||
if err != nil {
|
||
return []Tweet{}, fmt.Errorf("in client, failed to connect to database: %v", err)
|
||
}
|
||
defer conn.Close(ctx)
|
||
|
||
rows, err := conn.Query(ctx, "SELECT tweet_id, tweet_text, username, created_at FROM tweets0x001 WHERE created_at >= NOW() - INTERVAL '37 day' ORDER BY created_at DESC")
|
||
// 37 day
|
||
if err != nil {
|
||
return []Tweet{}, fmt.Errorf("failed to query tweets: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
validAccounts, err := getAccounts(accountsList)
|
||
if err != nil {
|
||
return []Tweet{}, fmt.Errorf("didn't get accounts: %v", err)
|
||
}
|
||
|
||
var sources []Tweet
|
||
for rows.Next() {
|
||
var s Tweet
|
||
var date time.Time
|
||
err := rows.Scan(&s.ID, &s.Text, &s.Username, &date)
|
||
s.CreatedAt = date.Format("2006-01-02 15:04:05")
|
||
if err != nil {
|
||
return []Tweet{}, fmt.Errorf("failed to scan row: %v", err)
|
||
}
|
||
if slices.Contains(validAccounts, s.Username) {
|
||
sources = append(sources, s)
|
||
}
|
||
}
|
||
|
||
return sources, nil
|
||
}
|
||
|
||
func getAccounts(accountsList string) ([]string, error){
|
||
accountsDir := "./lists"
|
||
accountsPath := filepath.Join(accountsDir, accountsList+".txt")
|
||
accountsData, err := os.ReadFile(accountsPath)
|
||
if err != nil {
|
||
return []string{}, fmt.Errorf("error reading accounts file: %v", err)
|
||
}
|
||
accounts := strings.Split(strings.TrimSpace(string(accountsData)), "\n")
|
||
|
||
// Filter out empty and commented accounts
|
||
var validAccounts []string
|
||
for _, account := range accounts {
|
||
if account = strings.TrimSpace(account); account != "" && !strings.HasPrefix(account, "#") {
|
||
validAccounts = append(validAccounts, account)
|
||
}
|
||
}
|
||
return validAccounts, nil
|
||
}
|
||
|
||
|
||
func (a *App) Run() error {
|
||
// Try to load from cache
|
||
allTweets, err := a.loadTweets(a.accountsList)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Filter tweets based on length preferences and username
|
||
a.tweets = make([]Tweet, 0, len(allTweets))
|
||
for _, tweet := range allTweets {
|
||
// Skip if username filter is set and doesn't match
|
||
if a.filterUsername != "" && tweet.Username != a.filterUsername {
|
||
continue
|
||
}
|
||
|
||
tweetLength := len(tweet.Text)
|
||
if (a.shortTweetsOnly && tweetLength < SHORT_TWEET_SIZE) ||
|
||
(a.longTweetsOnly && tweetLength >= SHORT_TWEET_SIZE) ||
|
||
(!a.shortTweetsOnly && !a.longTweetsOnly) {
|
||
a.tweets = append(a.tweets, tweet)
|
||
}
|
||
}
|
||
|
||
if len(a.tweets) == 0 {
|
||
if a.filterUsername != "" {
|
||
return fmt.Errorf("no tweets found from user @%s", a.filterUsername)
|
||
}
|
||
return fmt.Errorf("no tweets match the current length filter")
|
||
}
|
||
|
||
for {
|
||
a.draw()
|
||
|
||
switch ev := a.screen.PollEvent().(type) {
|
||
case *tcell.EventKey:
|
||
startIdx, endIdx := a.getPageBoundaries(a.currentPage)
|
||
|
||
switch ev.Key() {
|
||
case tcell.KeyEscape, tcell.KeyCtrlC:
|
||
return nil
|
||
case tcell.KeyUp:
|
||
if a.selectedIdx > 0 {
|
||
a.selectedIdx--
|
||
if a.selectedIdx < startIdx {
|
||
a.currentPage--
|
||
}
|
||
}
|
||
case tcell.KeyDown:
|
||
if a.selectedIdx < len(a.tweets)-1 {
|
||
a.selectedIdx++
|
||
if a.selectedIdx >= endIdx {
|
||
a.currentPage++
|
||
}
|
||
}
|
||
case tcell.KeyRight:
|
||
nextStart, _ := a.getPageBoundaries(a.currentPage + 1)
|
||
if nextStart < len(a.tweets) {
|
||
a.currentPage++
|
||
a.selectedIdx = nextStart
|
||
}
|
||
case tcell.KeyLeft:
|
||
if a.currentPage > 0 {
|
||
a.currentPage--
|
||
prevStart, _ := a.getPageBoundaries(a.currentPage)
|
||
a.selectedIdx = prevStart
|
||
}
|
||
case tcell.KeyRune:
|
||
switch ev.Rune() {
|
||
case 'h':
|
||
if a.currentPage > 0 {
|
||
a.currentPage--
|
||
prevStart, _ := a.getPageBoundaries(a.currentPage)
|
||
a.selectedIdx = prevStart
|
||
}
|
||
case 'j':
|
||
if a.selectedIdx < len(a.tweets)-1 {
|
||
a.selectedIdx++
|
||
if a.selectedIdx >= endIdx {
|
||
a.currentPage++
|
||
}
|
||
}
|
||
case 'k':
|
||
if a.selectedIdx > 0 {
|
||
a.selectedIdx--
|
||
if a.selectedIdx < startIdx {
|
||
a.currentPage--
|
||
}
|
||
}
|
||
case 'l':
|
||
nextStart, _ := a.getPageBoundaries(a.currentPage + 1)
|
||
if nextStart < len(a.tweets) {
|
||
a.currentPage++
|
||
a.selectedIdx = nextStart
|
||
}
|
||
case 'q', 'Q':
|
||
return nil
|
||
case 'o', 'O', 'r', 'R':
|
||
if err := a.handleCommand(ev.Rune()); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
case *tcell.EventResize:
|
||
a.screen.Sync()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) getPageBoundaries(page int) (startIdx, endIdx int) {
|
||
width, height := a.screen.Size()
|
||
availableHeight := height - 0
|
||
|
||
// Find start index by walking through previous pages
|
||
startIdx = 0
|
||
currentY := 0
|
||
for p := 0; p < page; p++ {
|
||
// Find where this page ends
|
||
for startIdx < len(a.tweets) {
|
||
tweetHeight := a.calculateTweetHeight(a.tweets[startIdx], width)
|
||
if currentY+tweetHeight > availableHeight {
|
||
currentY = 0
|
||
break
|
||
}
|
||
currentY += tweetHeight
|
||
startIdx++
|
||
}
|
||
}
|
||
|
||
// Find end index by calculating how many tweets fit from the start
|
||
endIdx = startIdx
|
||
currentY = 0
|
||
for endIdx < len(a.tweets) {
|
||
tweetHeight := a.calculateTweetHeight(a.tweets[endIdx], width)
|
||
if currentY+tweetHeight > availableHeight {
|
||
break
|
||
}
|
||
currentY += tweetHeight
|
||
endIdx++
|
||
}
|
||
|
||
return startIdx, endIdx
|
||
}
|
||
|
||
func convertToNormal(text string) string {
|
||
replacements := map[rune]rune{
|
||
// Lowercase mappings
|
||
'𝗮': 'a', '𝗯': 'b', '𝗰': 'c', '𝗱': 'd', '𝗲': 'e', '𝗳': 'f',
|
||
'𝗴': 'g', '𝗵': 'h', '𝗶': 'i', '𝗷': 'j', '𝗸': 'k', '𝗹': 'l',
|
||
'𝗺': 'm', '𝗻': 'n', '𝗼': 'o', '𝗽': 'p', '𝗾': 'q', '𝗿': 'r',
|
||
'𝘀': 's', '𝘁': 't', '𝘂': 'u', '𝘃': 'v', '𝘄': 'w',
|
||
'𝘅': 'x', '𝘆': 'y', '𝘇': 'z',
|
||
|
||
// Uppercase mappings
|
||
'𝗔': 'A', '𝗕': 'B', '𝗖': 'C', '𝗗': 'D', '𝗘': 'E', '𝗙': 'F',
|
||
'𝗚': 'G', '𝗛': 'H', '𝗜': 'I', '𝗝': 'J', '𝗞': 'K', '𝗟': 'L',
|
||
'𝗠': 'M', '𝗡': 'N', '𝗢': 'O', '𝗣': 'P', '𝗤': 'Q', '𝗥': 'R',
|
||
'𝗦': 'S', '𝗧': 'T', '𝗨': 'U', '𝗩': 'V', '𝗪': 'W',
|
||
'𝗫': 'X', '𝗬': 'Y', '𝗭': 'Z',
|
||
}
|
||
|
||
var result strings.Builder
|
||
for _, char := range text {
|
||
if normalChar, found := replacements[char]; found {
|
||
result.WriteRune(normalChar)
|
||
} else {
|
||
result.WriteRune(char) // if character is not in map, keep it as is
|
||
}
|
||
}
|
||
return result.String()
|
||
}
|
||
|
||
func (a *App) draw() {
|
||
a.screen.Clear()
|
||
width, height := a.screen.Size()
|
||
style := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorWhite)
|
||
selectedStyle := tcell.StyleDefault.Background(tcell.Color24).Foreground(tcell.ColorWhite)
|
||
|
||
// Get the tweets that fit on this page
|
||
startIdx, endIdx := a.getPageBoundaries(a.currentPage)
|
||
|
||
// Draw the tweets
|
||
currentY := 0
|
||
for idx := startIdx; idx < endIdx; idx++ {
|
||
tweet := a.tweets[idx]
|
||
currentStyle := style
|
||
if idx == a.selectedIdx {
|
||
currentStyle = selectedStyle
|
||
}
|
||
|
||
// Cap tweet text length at 2500 characters
|
||
text := tweet.Text
|
||
if len(text) > 2500 {
|
||
text = text[:2500] + "..."
|
||
}
|
||
text = convertToNormal(text)
|
||
|
||
tweetLine := fmt.Sprintf("[%d] @%s: %s | %s", idx, tweet.Username, text, tweet.CreatedAt)
|
||
lastY := drawText(a.screen, 0, currentY, width, currentStyle, tweetLine)
|
||
currentY = lastY + 1
|
||
}
|
||
|
||
// Draw help text at the bottom
|
||
helpText := "^/v: Navigate | <>: Change Page | o: Open | r: Reply | q: Quit"
|
||
if height > 0 {
|
||
drawText(a.screen, 0, height-1, width, style, helpText)
|
||
}
|
||
|
||
a.screen.Show()
|
||
}
|
||
|
||
func (a *App) calculateTweetHeight(tweet Tweet, width int) int {
|
||
// Cap tweet text length at 2500 characters
|
||
text := tweet.Text
|
||
if len(text) > 2500 {
|
||
text = text[:2500] + "..."
|
||
}
|
||
if tweet.Username == "VOCPEnglish" {
|
||
text = convertToNormal(text)
|
||
}
|
||
|
||
// Format the tweet line as it will be displayed
|
||
tweetLine := fmt.Sprintf("[%d] @%s: %s | %s", tweet.ID, tweet.Username, text, tweet.CreatedAt)
|
||
|
||
// Count how many lines this tweet will take using proper Unicode width
|
||
lines := 1
|
||
currentLineWidth := 0
|
||
for _, r := range tweetLine {
|
||
runeWidth := runewidth.RuneWidth(r)
|
||
if currentLineWidth+runeWidth > width {
|
||
lines++
|
||
currentLineWidth = runeWidth
|
||
} else {
|
||
currentLineWidth += runeWidth
|
||
}
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func drawText(screen tcell.Screen, x, y, maxWidth int, style tcell.Style, text string) int {
|
||
currentX := x
|
||
currentY := y
|
||
|
||
for _, r := range text {
|
||
runeWidth := runewidth.RuneWidth(r)
|
||
if currentX+runeWidth > x+maxWidth {
|
||
// Move to next line
|
||
currentY++
|
||
currentX = x
|
||
}
|
||
screen.SetContent(currentX, currentY, r, nil, style)
|
||
currentX += runeWidth
|
||
}
|
||
return currentY
|
||
}
|
||
|
||
func (a *App) handleCommand(cmd rune) error {
|
||
if len(a.tweets) == 0 {
|
||
return nil
|
||
}
|
||
|
||
currentTweet := a.tweets[a.selectedIdx]
|
||
switch cmd {
|
||
case 'o', 'O':
|
||
// Open tweet in browser
|
||
tweetURL := fmt.Sprintf("https://twitter.com/user/status/%s", currentTweet.ID)
|
||
return openBrowser(tweetURL)
|
||
case 'r':
|
||
// Get reply text from user
|
||
reply := a.getInput("Reply: ")
|
||
if reply == "" {
|
||
return nil // User cancelled
|
||
}
|
||
|
||
// Save current screen state and clean up terminal
|
||
a.screen.Fini()
|
||
fmt.Print("\033[H\033[2J") // Clear screen
|
||
|
||
// Cap tweet text length for display
|
||
text := currentTweet.Text
|
||
if len(text) > 2500 {
|
||
text = text[:2500] + "..."
|
||
}
|
||
|
||
// Show feedback
|
||
fmt.Printf("Sending reply to tweet: %s\n", text)
|
||
fmt.Printf("Your reply: %s\n", reply)
|
||
|
||
// Construct the command that will be executed
|
||
cmdStr := fmt.Sprintf("tweet -d %s %q", currentTweet.ID, reply)
|
||
|
||
// Show the command that will be executed
|
||
fmt.Printf("\nExecuting command:\n$ %s\n\n", cmdStr)
|
||
|
||
// Use split to separate command and its arguments
|
||
cmdArgs := []string{"-i", "-c", cmdStr}
|
||
bashCmd := exec.Command("bash", cmdArgs...) // Use exec.Command directly, no need for quotes
|
||
err := bashCmd.Run()
|
||
|
||
// Show result and wait for user input
|
||
if err != nil {
|
||
fmt.Printf("\nError sending reply: %v\n", err)
|
||
} else {
|
||
fmt.Println("\nReply sent successfully!")
|
||
}
|
||
fmt.Print("\nPress Enter to continue...")
|
||
fmt.Scanln() // Wait for Enter key
|
||
|
||
// Reinitialize screen
|
||
screen, err := tcell.NewScreen()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create new screen: %v", err)
|
||
}
|
||
if err := screen.Init(); err != nil {
|
||
return fmt.Errorf("failed to initialize new screen: %v", err)
|
||
}
|
||
a.screen = screen
|
||
/*
|
||
case 'R':
|
||
allTweets, ok := loadFromCache(a.accountsList)
|
||
if !ok {
|
||
return fmt.Errorf("no cached tweets available - please run fetcher first")
|
||
}
|
||
|
||
// Filter tweets based on length preferences and username
|
||
a.tweets = make([]Tweet, 0, len(allTweets))
|
||
for _, tweet := range allTweets {
|
||
// Skip if username filter is set and doesn't match
|
||
if a.filterUsername != "" && tweet.Username != a.filterUsername {
|
||
continue
|
||
}
|
||
|
||
tweetLength := len(tweet.Text)
|
||
if (a.shortTweetsOnly && tweetLength < SHORT_TWEET_SIZE) ||
|
||
(a.longTweetsOnly && tweetLength >= SHORT_TWEET_SIZE) ||
|
||
(!a.shortTweetsOnly && !a.longTweetsOnly) {
|
||
a.tweets = append(a.tweets, tweet)
|
||
}
|
||
}
|
||
|
||
// Reset selection if it's now out of bounds
|
||
if a.selectedIdx >= len(a.tweets) {
|
||
a.selectedIdx = len(a.tweets) - 1
|
||
}
|
||
if a.selectedIdx < 0 {
|
||
a.selectedIdx = 0
|
||
}
|
||
*/
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) getInput(prompt string) string {
|
||
// Clear bottom of screen
|
||
width, height := a.screen.Size()
|
||
style := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorWhite)
|
||
|
||
// Create a buffer for multiline input
|
||
var lines []string
|
||
currentLine := ""
|
||
cursorX := len(prompt)
|
||
cursorY := height - 3
|
||
|
||
// Show initial prompt and instructions
|
||
drawText(a.screen, 0, cursorY-1, width, style, "Enter your reply (Ctrl+J to submit, Enter for new line, Esc to cancel):")
|
||
drawText(a.screen, 0, cursorY, width, style, prompt)
|
||
a.screen.Show()
|
||
|
||
for {
|
||
ev := a.screen.PollEvent()
|
||
switch ev := ev.(type) {
|
||
case *tcell.EventKey:
|
||
switch ev.Key() {
|
||
case tcell.KeyCtrlJ:
|
||
if currentLine != "" {
|
||
lines = append(lines, currentLine)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case tcell.KeyEscape:
|
||
return ""
|
||
case tcell.KeyEnter:
|
||
lines = append(lines, currentLine)
|
||
currentLine = ""
|
||
cursorX = len(prompt)
|
||
cursorY++
|
||
if cursorY >= height-1 {
|
||
cursorY--
|
||
a.screen.Clear()
|
||
drawText(a.screen, 0, cursorY-len(lines)-1, width, style, "Enter your reply (Ctrl+J to submit, Enter for new line, Esc to cancel):")
|
||
for i, line := range lines {
|
||
drawText(a.screen, 0, cursorY-len(lines)+i, width, style, prompt+line)
|
||
}
|
||
}
|
||
drawText(a.screen, 0, cursorY, width, style, prompt)
|
||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||
if len(currentLine) > 0 {
|
||
currentLine = currentLine[:len(currentLine)-1]
|
||
cursorX--
|
||
a.screen.SetContent(cursorX, cursorY, ' ', nil, style)
|
||
} else if len(lines) > 0 {
|
||
currentLine = lines[len(lines)-1]
|
||
lines = lines[:len(lines)-1]
|
||
cursorY--
|
||
cursorX = len(prompt) + len(currentLine)
|
||
}
|
||
case tcell.KeyRune:
|
||
currentLine += string(ev.Rune())
|
||
a.screen.SetContent(cursorX, cursorY, ev.Rune(), nil, style)
|
||
cursorX++
|
||
}
|
||
|
||
// Redraw current line
|
||
for x := 0; x < width; x++ {
|
||
a.screen.SetContent(x, cursorY, ' ', nil, style)
|
||
}
|
||
drawText(a.screen, 0, cursorY, width, style, prompt+currentLine)
|
||
a.screen.Show()
|
||
}
|
||
}
|
||
}
|
||
|
||
func openBrowser(url string) error {
|
||
var cmd string
|
||
var args []string
|
||
|
||
switch runtime.GOOS {
|
||
case "windows":
|
||
cmd = "cmd"
|
||
args = []string{"/c", "start"}
|
||
case "darwin":
|
||
cmd = "open"
|
||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||
cmd = "xdg-open"
|
||
}
|
||
args = append(args, url)
|
||
return exec.Command(cmd, args...).Start()
|
||
}
|
||
|
||
|
||
func main() {
|
||
// Define command line flags
|
||
list := flag.String("list", "all", "Read a specific accounts list from data. By default all (all.txt, all.json)")
|
||
shortTweets := flag.Bool("short", false, "Show only short tweets (<180 characters)")
|
||
longTweets := flag.Bool("long", false, "Show only long tweets (>=180 characters)")
|
||
username := flag.String("u", "", "Show tweets only from this username")
|
||
flag.Parse()
|
||
|
||
app, err := newApp()
|
||
if err != nil {
|
||
fmt.Printf("Could not create client: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Configure tweet filtering
|
||
if *shortTweets && *longTweets {
|
||
fmt.Println("Warning: Both --short and --long flags set. Showing all tweets.")
|
||
} else {
|
||
app.shortTweetsOnly = *shortTweets
|
||
app.longTweetsOnly = *longTweets
|
||
}
|
||
|
||
// Configure which accounts list to use
|
||
app.accountsList = *list
|
||
app.filterUsername = *username
|
||
|
||
if err := app.Run(); err != nil {
|
||
app.screen.Fini()
|
||
fmt.Printf("Error running client: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
app.screen.Fini()
|
||
}
|