refactor: reorganize project structure and update configurations

- Move migrations/ to database/schema/ for clarity on database schema definitions
- Move sqlc.yaml to internal/database/ to group with database code
- Move static/ to cmd/server/static/ to co-locate with server
- Update all configuration files and documentation
- Follow Go project conventions for better organization
This commit is contained in:
2026-01-24 23:40:31 -05:00
parent 8a951fb242
commit 08e80ae84b
34 changed files with 532 additions and 104 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ type Querier interface {
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error)
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
+12 -5
View File
@@ -223,8 +223,8 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err
}
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :one
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING id, user_id, folder_path, created_at
`
type DeleteUserEbookFolderParams struct {
@@ -232,9 +232,16 @@ type DeleteUserEbookFolderParams struct {
FolderPath string `db:"folder_path" json:"folder_path"`
}
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error {
_, err := q.db.Exec(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
return err
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error) {
row := q.db.QueryRow(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
var i UserEbookFolders
err := row.Scan(
&i.ID,
&i.UserID,
&i.FolderPath,
&i.CreatedAt,
)
return i, err
}
const GetEbook = `-- name: GetEbook :one
+2 -2
View File
@@ -131,8 +131,8 @@ INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING
-- name: GetUserEbookFolders :many
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
-- name: DeleteUserEbookFolder :exec
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2;
-- name: DeleteUserEbookFolder :one
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING *;
-- name: GetEbookByFilePath :one
SELECT * FROM ebooks WHERE file_path = $1;
+17
View File
@@ -0,0 +1,17 @@
version: "2"
sql:
- engine: "postgresql"
schema: "database/schema"
queries: "internal/database/queries"
gen:
go:
package: "database"
out: "internal/database"
sql_package: "pgx/v5"
emit_db_tags: true
emit_prepared_queries: true
emit_interface: true
emit_exact_table_names: true
emit_empty_slices: true
emit_exported_queries: true
emit_json_tags: true
+52 -3
View File
@@ -4,11 +4,14 @@ import (
"bookmann/internal/database"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
@@ -370,6 +373,31 @@ type AddEbookFolderRequest struct {
FolderPath string `json:"folder_path" validate:"required"`
}
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
func normalizePath(path string) string {
fmt.Printf("normalizePath input: '%s'\n", path)
var cleaned string
// Handle home directory expansion (~)
if strings.HasPrefix(path, "~/") {
// Keep the original path for ~ to preserve user's formatting
// Just normalize separators and that's it
cleaned = strings.ReplaceAll(path, "\\", "/")
} else {
// Clean the path to remove redundant separators, ., .. etc.
cleaned = filepath.Clean(path)
// Convert to consistent path separators (use forward slashes for storage)
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
// Remove trailing slash unless it's root path
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
cleaned = strings.TrimSuffix(cleaned, "/")
}
}
fmt.Printf("normalizePath output: '%s'\n", cleaned)
return cleaned
}
type EbookFolderResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -397,9 +425,12 @@ func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Normalize the folder path before storing
normalizedPath := normalizePath(req.FolderPath)
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: req.FolderPath,
FolderPath: normalizedPath,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -426,6 +457,12 @@ func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
fmt.Printf("GetEbookFolders: user_id='%s'\n", userID)
for _, folder := range folders {
fmt.Printf(" Folder in DB: id='%s', path='%s'\n",
uuid.UUID(folder.ID.Bytes).String(), folder.FolderPath)
}
var response []EbookFolderResponse
for _, folder := range folders {
response = append(response, EbookFolderResponse{
@@ -455,14 +492,26 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
// Normalize the folder path before deletion
normalizedPath := normalizePath(req.FolderPath)
// Debug logging - remove in production
fmt.Printf("DeleteEbookFolder: original path='%s', normalized path='%s', user_id='%s'\n",
req.FolderPath, normalizedPath, userID)
deletedFolder, err := h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: req.FolderPath,
FolderPath: normalizedPath,
})
if err != nil {
fmt.Printf("DeleteEbookFolder failed: %v\n", err)
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "ebook folder not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
fmt.Printf("DeleteEbookFolder succeeded: deleted folder with path '%s'\n", deletedFolder.FolderPath)
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder deleted successfully"})
}
+32 -7
View File
@@ -426,7 +426,7 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
// ScanEbooksRequest represents the request for scanning ebooks
type ScanEbooksRequest struct {
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
FolderPaths []string `json:"folder_paths,omitempty"`
}
// ScanEbooks handles POST /api/scanner/scan
@@ -435,12 +435,40 @@ func (h *Handler) ScanEbooks(c echo.Context) error {
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
// Get user ID from JWT token
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var folderPaths []string
// If folder paths provided in request, use them
// Otherwise, use user's saved folders
if len(req.FolderPaths) > 0 {
folderPaths = req.FolderPaths
} else {
// Get user's configured ebook folders
folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user folders: " + err.Error()})
}
if len(folders) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no folders configured for user"})
}
// Convert to folder paths
folderPaths = make([]string, len(folders))
for i, folder := range folders {
folderPaths[i] = folder.FolderPath
}
}
// Set the folder paths for scanning
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
if err := h.scanner.SetFolders(folderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
@@ -461,9 +489,6 @@ func (h *Handler) StartScanner(c echo.Context) error {
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
+204 -15
View File
@@ -7,11 +7,14 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -25,6 +28,8 @@ type EbookMetadata struct {
PublishDate time.Time
Contributors string
CoverPath string
ISBN string
Tags string
}
type EbookScanner struct {
@@ -75,12 +80,28 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
return fmt.Errorf("no folders set")
}
fmt.Printf("Starting scan of %d folders: %v\n", len(s.folders), s.folders)
totalFiles := 0
ebookFiles := 0
for _, folder := range s.folders {
fmt.Printf("Scanning folder: %s\n", folder)
// Check if folder exists
if _, err := os.Stat(folder); os.IsNotExist(err) {
fmt.Printf("Folder does not exist: %s\n", folder)
continue
}
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
return err
}
totalFiles++
if d.IsDir() {
// Also watch subdirectories
if err := s.watcher.Add(path); err != nil {
@@ -91,8 +112,12 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
// Check if it's an ebook file
if s.isEbookFile(path) {
ebookFiles++
fmt.Printf("Found ebook file: %s\n", path)
if err := s.processEbookFile(ctx, path); err != nil {
fmt.Printf("Error processing ebook %s: %v\n", path, err)
} else {
fmt.Printf("Successfully processed ebook: %s\n", path)
}
}
@@ -103,6 +128,7 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
}
}
fmt.Printf("Scan completed: %d total files scanned, %d ebook files found\n", totalFiles, ebookFiles)
return nil
}
@@ -116,38 +142,140 @@ func (s *EbookScanner) isEbookFile(path string) bool {
}
}
// extractFolderStructureMetadata extracts metadata from folder paths, prioritizing Calibre structure
func (s *EbookScanner) extractFolderStructureMetadata(path, rootFolder string) *EbookMetadata {
metadata := &EbookMetadata{}
// Get the relative path from root folder
relPath, err := filepath.Rel(rootFolder, path)
if err != nil {
return metadata
}
// Split into directory components
dir := filepath.Dir(relPath)
components := strings.Split(dir, string(filepath.Separator))
if len(components) < 2 {
return metadata // Not enough structure to extract
}
// Calibre structure detection
// Pattern 1: Author Name/Book Title/
// Pattern 2: Author Name/Series Name/Book Title/
// Pattern 3: Author Name/Series Name, Book #1 - Book Title/
author := strings.TrimSuffix(components[0], "_") // Remove trailing underscore if present
metadata.Author = strings.ReplaceAll(author, "_", " ")
if len(components) >= 3 {
// This might be a series structure
possibleSeries := components[1]
possibleTitle := components[2]
// Check for Calibre series format: "Series Name, Book #1 - Title"
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
if len(seriesMatch) == 4 {
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
} else {
// Simple series structure: Author/Series/Title
metadata.Series = strings.ReplaceAll(possibleSeries, "_", " ")
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
// Try to extract series number from title
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+)$`).FindStringSubmatch(metadata.Title)
if len(titleNumMatch) == 3 {
metadata.Title = titleNumMatch[1]
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
}
}
} else {
// Simple structure: Author/Title
metadata.Title = strings.ReplaceAll(components[1], "_", " ")
}
return metadata
}
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
fmt.Printf("Processing ebook file: %s\n", path)
// Get file info
info, err := os.Stat(path)
if err != nil {
fmt.Printf("Failed to get file info for %s: %v\n", path, err)
return fmt.Errorf("failed to get file info: %v", err)
}
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
// Check if ebook already exists in database
existingEbook, err := s.getEbookByFilePath(ctx, path)
if err == nil {
fmt.Printf("Ebook already exists in database: %s (size: %d vs %d)\n", path, existingEbook.FileSize.Int64, info.Size())
// Ebook exists, check if file has changed (by size)
if existingEbook.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating ebook: %s\n", path)
return s.updateEbook(ctx, existingEbook.ID, path, info)
}
fmt.Printf("Ebook already exists with same size, skipping: %s\n", path)
return nil // Skip if already exists and size matches
} else if err.Error() != "sql: no rows in result set" {
} else if err != pgx.ErrNoRows && !strings.Contains(err.Error(), "no rows") {
fmt.Printf("Database error checking ebook existence: %v\n", err)
// Some other error occurred
return fmt.Errorf("failed to check if ebook exists: %v", err)
}
// Ebook doesn't exist, continue with creation
fmt.Printf("Ebook does not exist in database, creating new entry: %s\n", path)
// Extract metadata
// Extract metadata from file first
metadata, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
// Continue with basic metadata
metadata = &EbookMetadata{
Title: filepath.Base(path),
Author: "Unknown",
metadata = &EbookMetadata{}
}
// Try to get metadata from folder structure as fallback/enhancement
// Use the root folder that contains this file
var rootFolder string
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
rootFolder = folder
break
}
}
if rootFolder != "" {
folderMetadata := s.extractFolderStructureMetadata(path, rootFolder)
// Use folder metadata as fallback for missing information
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
}
if metadata.Author == "" && folderMetadata.Author != "" {
metadata.Author = folderMetadata.Author
}
if metadata.Series == "" && folderMetadata.Series != "" {
metadata.Series = folderMetadata.Series
}
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
metadata.SeriesNumber = folderMetadata.SeriesNumber
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if metadata.Author == "" {
metadata.Author = "Unknown"
}
// Create ebook in database
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
Title: metadata.Title,
@@ -162,6 +290,7 @@ func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
})
return err
@@ -173,10 +302,12 @@ func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
switch ext {
case ".epub":
return s.extractEPUBMetadata(path)
case ".pdf":
return s.extractPDFMetadata(path)
default:
// For other formats, return basic metadata
return &EbookMetadata{
Title: filepath.Base(path),
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
}
}
@@ -209,10 +340,25 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
metadata.Publisher = publishers[0]
}
// Series and series number (Calibre specific metadata)
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
metadata.Series = series[0]
}
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
// Publish date
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", dates[0]); err == nil {
metadata.PublishDate = date
}
}
}
@@ -221,9 +367,38 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
metadata.Contributors = strings.Join(contributors, ", ")
}
// ISBN
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
for _, isbn := range isbns {
if strings.Contains(strings.ToLower(isbn), "isbn") {
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
isbnParts := strings.SplitN(isbn, ":", 2)
if len(isbnParts) == 2 {
metadata.ISBN = isbnParts[1]
break
}
}
}
}
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
metadata.Tags = strings.Join(tags, ", ")
}
return metadata, nil
}
func (s *EbookScanner) extractPDFMetadata(path string) (*EbookMetadata, error) {
// For now, return basic metadata since PDF extraction requires additional libraries
// In a future enhancement, you could use libraries like github.com/ledongthuc/pdf
filename := strings.TrimSuffix(filepath.Base(path), ".pdf")
return &EbookMetadata{
Title: filename,
}, nil
}
func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error {
metadata, err := s.extractMetadata(filePath)
if err != nil {
@@ -237,12 +412,12 @@ func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, fil
ID: ebookID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{}, // Keep existing ISBN
Isbn: pgtype.Text{String: metadata.ISBN, Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Tags: pgtype.Text{}, // Keep existing tags
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
Asin: pgtype.Text{}, // Keep existing ASIN
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
@@ -284,14 +459,28 @@ func (s *EbookScanner) WatchChanges(ctx context.Context) {
if !ok {
return
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
if s.isEbookFile(event.Name) {
fmt.Printf("New/modified ebook detected: %s\n", event.Name)
if err := s.processEbookFile(ctx, event.Name); err != nil {
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err)
// Handle new directories - add them to the watcher
if event.Has(fsnotify.Create) {
info, err := os.Stat(event.Name)
if err == nil && info.IsDir() {
// Add the new directory to the watcher
if err := s.watcher.Add(event.Name); err != nil {
fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err)
} else {
fmt.Printf("Now watching new directory: %s\n", event.Name)
}
}
}
// Handle file modifications and creations
if (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) && s.isEbookFile(event.Name) {
fmt.Printf("New/modified ebook detected: %s\n", event.Name)
if err := s.processEbookFile(ctx, event.Name); err != nil {
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err)
}
}
case err, ok := <-s.watcher.Errors:
if !ok {
return