2025-08-01 20:00:46 +03:00

340 lines
8.4 KiB
Go

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
}
// --- NEW: Safely add new columns for read status ---
addColumn(db, "books", "tuomas_read", "INTEGER DEFAULT 0")
addColumn(db, "books", "jenni_read", "INTEGER DEFAULT 0")
// --- END NEW ---
log.Println("Database initialized successfully.")
return db, nil
}
// --- NEW: 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_, &notnull, &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)
}
}
// --- END NEW ---
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,
})
})
// --- NEW: 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})
})
// --- END NEW ---
log.Println("Starting server on http://localhost:8080/books")
r.Run(":8080")
}
// --- NEW: 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
}
// --- END NEW ---
// Modified crawl_dua to not require the ticker argument
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[href*='/books/']", 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)
})
bookCollector.Visit(currenturl)
// On the book page, extract the details
bookCollector.OnHTML(`div.book-page`, 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 {
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) {
// --- UPDATED: Select the new columns ---
query := "SELECT id, name, image_path, tuomas_read, jenni_read FROM books 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
}