package main import ( "database/sql" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gocolly/colly" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" // SQLite driver ) // Book struct now includes boolean fields for read status type Book struct { ID int Name string ImagePath string TuomasRead bool JenniRead bool } const dbFile = "books.db" const imagesDir = "./images" func main() { db, err := initDB() if err != nil { log.Fatal("Failed to initialize database:", err) } defer db.Close() ticker := time.NewTicker(6 * time.Hour) go func() { log.Println("Running initial crawl on startup...") crawl_dua(db) }() go func() { for t := range ticker.C { log.Printf("Running scheduled crawl at %v", t) crawl_dua(db) } }() startServer(db) } // initDB now safely adds the new columns if they don't already exist. func initDB() (*sql.DB, error) { db, err := sql.Open("sqlite3", dbFile) if err != nil { return nil, err } createTableSQL := `CREATE TABLE IF NOT EXISTS books ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL UNIQUE, "image_path" TEXT );` if _, err = db.Exec(createTableSQL); err != nil { return nil, err } // --- Safely add new columns for read status --- addColumn(db, "books", "tuomas_read", "INTEGER DEFAULT 0") addColumn(db, "books", "jenni_read", "INTEGER DEFAULT 0") // --- NEW: Add hidden column --- addColumn(db, "books", "hidden", "INTEGER DEFAULT 0") log.Println("Database initialized successfully.") return db, nil } // Helper function to add a column only if it doesn't already exist. func addColumn(db *sql.DB, tableName, columnName, columnType string) { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) if err != nil { log.Printf("Error checking table info for %s: %v", tableName, err) return } defer rows.Close() for rows.Next() { var cid, notnull, pk int var name, type_ string var dflt_value sql.NullString if err := rows.Scan(&cid, &name, &type_, ¬null, &dflt_value, &pk); err != nil { log.Printf("Error scanning table info row: %v", err) return } if name == columnName { return // Column already exists, do nothing. } } log.Printf("Column '%s' not found. Adding it to table '%s'.", columnName, tableName) _, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, columnName, columnType)) if err != nil { log.Printf("Failed to add column '%s' to table '%s': %v", columnName, tableName, err) } else { log.Printf("Successfully added column '%s'.", columnName) } } func startServer(db *sql.DB) { r := gin.Default() r.Static("/images", imagesDir) r.LoadHTMLGlob("templates/*.html") r.GET("/", func(c *gin.Context) { books, err := getAllBooks(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve books"}) return } c.HTML(http.StatusOK, "books.html", gin.H{ "books": books, }) }) // Endpoint to handle updating the read status r.POST("/books/:id/update", func(c *gin.Context) { idStr := c.Param("id") var payload struct { Person string `json:"person"` Read bool `json:"read"` } if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"}) return } bookID, err := strconv.Atoi(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"}) return } err = updateReadStatus(db, bookID, payload.Person, payload.Read) if err != nil { log.Printf("Error updating status for book %d: %v", bookID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"}) return } c.JSON(http.StatusOK, gin.H{"status": "success", "person": payload.Person, "read": payload.Read}) }) log.Println("Starting server on http://localhost:8080/books") r.Run(":8080") } // Function to update the read status in the database func updateReadStatus(db *sql.DB, bookID int, person string, read bool) error { var columnName string switch strings.ToLower(person) { case "tuomas": columnName = "tuomas_read" case "jenni": columnName = "jenni_read" default: return fmt.Errorf("invalid person specified: %s", person) } updateSQL := fmt.Sprintf("UPDATE books SET %s = ? WHERE id = ?", columnName) statement, err := db.Prepare(updateSQL) if err != nil { return err } defer statement.Close() _, err = statement.Exec(read, bookID) log.Printf("Updated book %d for %s, set read status to %v", bookID, person, read) return err } func crawl_dua(db *sql.DB) { // The main page is a better starting point to find all books currenturl := "https://www.service95.com/book-club/" if err := os.MkdirAll(imagesDir, os.ModePerm); err != nil { log.Println("Error creating images directory:", err) } c := colly.NewCollector( colly.AllowedDomains("www.service95.com"), colly.MaxDepth(1), // Only visit links on the main page ) bookCollector := c.Clone() // Find links to individual book pages c.OnHTML("a", func(e *colly.HTMLElement) { link := e.Attr("href") // Make sure it's a valid book link and not something else if strings.Contains(link, "/books/") { bookCollector.Visit(e.Request.AbsoluteURL(link)) } }) c.OnRequest(func(r *colly.Request) { fmt.Println("Visiting", r.URL) }) // On the book page, extract the details bookCollector.OnHTML(`div`, func(e *colly.HTMLElement) { title := e.ChildText("h1") imgSrc := e.ChildAttr("img", "src") var month string e.ForEachWithBreak("p", func(_ int, el *colly.HTMLElement) bool { if strings.Contains(el.Text, "Monthly") { month = el.Text return false // stop iterating } return true // continue iterating }) if title == "" || month == "" || !strings.Contains(imgSrc, "uploads") { return } exists, err := bookExists(db, title) if err != nil { log.Printf("Error checking if book '%s' exists: %v", title, err) return } if exists { log.Println("SKipping book: ", title) return // Silently skip if already exists } absoluteImgURL := e.Request.AbsoluteURL(imgSrc) localImagePath, err := downloadImage(absoluteImgURL) if err != nil { log.Printf("Failed to download image for '%s': %v", title, err) return } newBook := Book{Name: title, ImagePath: localImagePath} err = addBook(db, newBook) if err != nil { log.Printf("Failed to add book '%s' to database: %v", title, err) } else { log.Printf("Successfully added book: %s", title) } }) c.Visit(currenturl) c.Wait() bookCollector.Wait() } func downloadImage(url string) (string, error) { response, err := http.Get(url) if err != nil { return "", err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return "", fmt.Errorf("received non-200 response code: %d", response.StatusCode) } // Use a UUID to prevent filename collisions id := uuid.New().String() + filepath.Ext(url) localPath := filepath.Join(imagesDir, id) file, err := os.Create(localPath) if err != nil { return "", err } defer file.Close() _, err = io.Copy(file, response.Body) if err != nil { return "", err } return "/" + filepath.ToSlash(localPath), nil } func addBook(db *sql.DB, book Book) error { // The new columns have default values, so we don't need to specify them here. insertSQL := `INSERT INTO books (name, image_path) VALUES (?, ?)` statement, err := db.Prepare(insertSQL) if err != nil { return err } defer statement.Close() _, err = statement.Exec(book.Name, book.ImagePath) return err } func bookExists(db *sql.DB, name string) (bool, error) { var count int query := `SELECT COUNT(*) FROM books WHERE name = ?` err := db.QueryRow(query, name).Scan(&count) if err != nil { return false, err } return count > 0, nil } // getAllBooks now retrieves the read status as well. func getAllBooks(db *sql.DB) ([]Book, error) { query := "SELECT id, name, image_path, tuomas_read, jenni_read FROM books WHERE hidden = 0 ORDER BY id DESC" rows, err := db.Query(query) if err != nil { return nil, err } defer rows.Close() var books []Book for rows.Next() { var b Book // --- UPDATED: Scan the new columns into the struct --- if err := rows.Scan(&b.ID, &b.Name, &b.ImagePath, &b.TuomasRead, &b.JenniRead); err != nil { return nil, err } books = append(books, b) } return books, nil }