334 lines
8.3 KiB
Go
334 lines
8.3 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
|
|
}
|
|
|
|
// --- 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
|
|
}
|