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
+2
View File
@@ -205,3 +205,5 @@ server
# Generated templ files
templates/*_templ.go
uploads/
+16 -2
View File
@@ -15,12 +15,26 @@ COPY . .
# Download dependencies
RUN go mod tidy
# Install Node.js and npm for Tailwind CSS building
RUN apk add --no-cache nodejs npm
# Copy package files and install npm dependencies
COPY package*.json ./
RUN npm install
# Copy source code (after npm to avoid re-running npm install on code changes)
COPY . .
# Generate sqlc code
RUN sqlc generate
# Generate templ code
RUN templ generate
# Build Tailwind CSS and download htmx
RUN npm run build:css:prod
RUN npm run postinstall
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
@@ -33,8 +47,8 @@ WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/main .
# Copy migrations (if needed for initialization)
COPY --from=builder /app/migrations ./migrations
# Copy database schema (if needed for initialization)
COPY --from=builder /app/database/schema ./database/schema
# Copy templates
COPY --from=builder /app/templates ./templates
+105 -24
View File
@@ -11,11 +11,15 @@ A self-hosted ebook management system built with Go, PostgreSQL, HTMX, and Tailw
- **🌙 Multiple Themes**: 11 beautiful themes including Tokyo Night, Dracula, Nord, Solarized Dark, Monokai, One Dark Pro, Material Dark, and Catppuccin variants (Mocha, Macchiato, Frappé, Latte) with user preferences saved to database
- **🎨 Theme Persistence**: User theme choices sync between browser and server
- **📖 Reading Progress**: User-specific reading progress tracking
- **⭐ User Ratings**: Rate and review ebooks with personalized rating system
- **📁 Multiple Folder Support**: Configure multiple ebook folders per user for comprehensive library management
- **🔍 Smart Scanner**: Automatic ebook discovery with EPUB metadata extraction (title, author, description, publisher, etc.)
- **🔍 Enhanced Scanner**: Intelligent ebook discovery with Calibre folder structure support and comprehensive metadata extraction
- **👀 Real-Time Monitoring**: File system monitoring for automatic ebook detection and updates
- **📚 Rich Metadata**: Automatic extraction of ebook metadata from EPUB files
- **🔧 RESTful API**: Clean API endpoints with JWT authentication
- **📚 Rich Metadata**: Automatic extraction of ebook metadata (title, author, description, publisher, series, ISBN, tags) from EPUB files with Calibre-specific support
- **🏛️ Calibre Integration**: Full support for Calibre folder structures and metadata (calibre:series, calibre:series_index)
- **📂 Smart Folder Detection**: Automatically detects Author/Book, Author/Series/Book, and Calibre naming conventions
- **🔄 Subfolder Scanning**: Recursively scans subdirectories with proper folder structure analysis
- **🔧 RESTful API**: Clean API endpoints with JWT authentication and proper error handling
- **🐳 Docker Ready**: Single-container deployment with PostgreSQL
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
- **📱 Responsive Design**: Mobile-first responsive interface using Tailwind CSS
@@ -110,19 +114,82 @@ The application uses Go HTML templates for server-side rendering with HTMX for d
- `GET /api/ebooks/:id/ratings` - Get all ratings for ebook
### Scanner (Protected)
- `POST /api/scanner/scan` - Manually scan configured folders for ebooks
- `POST /api/scanner/scan` - Scan user's configured ebook folders (supports optional folder_paths parameter for testing)
- `POST /api/scanner/start` - Start real-time monitoring of configured folders
- `POST /api/scanner/stop` - Stop real-time folder monitoring
## 📁 Ebook Scanner
## 📁 Enhanced Ebook Scanner
Bookmann includes an intelligent ebook scanner that can automatically discover and catalog ebooks from your configured folders.
Bookmann includes an intelligent ebook scanner with full Calibre integration and smart folder structure detection.
### Setting Up Folders
1. **Add Folders**: Use the API or Bruno to add ebook folders to your account
2. **Supported Formats**: EPUB, PDF, MOBI, AZW3, FB2, TXT
3. **Metadata Extraction**: EPUB files automatically get rich metadata (title, author, description, publisher, etc.)
1. **Add Folders**: Use `POST /api/auth/ebook-folders` to add ebook folders to your account
2. **Supported Formats**: EPUB (full metadata), PDF (basic), MOBI, AZW3, FB2, TXT
3. **Calibre Integration**: Automatically recognizes Calibre folder structures and metadata
### Folder Structure Support
**Calibre Structure (Preferred)**
- `Author Name/Book Title/` - Simple Calibre structure
- `Author Name/Series Name/Book Title/` - Series-based structure
- `Author Name/Series Name, Book #1 - Book Title/` - Full Calibre naming with series numbers
**Alternative Structures**
- Flat folder structures (all ebooks in root folder)
- Custom subfolder organization
- Mixed structures (Calibre + custom folders)
### Example Folder Structures
**Calibre Standard**
```
Books/
├── Brandon Sanderson/
│ ├── Mistborn/
│ │ ├── The Final Empire.epub
│ │ └── The Well of Ascension.epub
│ └── The Stormlight Archive/
│ ├── The Way of Kings.epub
│ └── Words of Radiance.epub
└── Patrick Rothfuss/
└── The Kingkiller Chronicle/
├── The Name of the Wind.epub
└── The Wise Man's Fear.epub
```
**Calibre with Series Numbers**
```
Books/
├── Brandon Sanderson/
│ ├── Mistborn Trilogy, Book #1 - The Final Empire/
│ │ └── The Final Empire.epub
│ └── Mistborn Trilogy, Book #2 - The Well of Ascension/
│ └── The Well of Ascension.epub
```
### Metadata Extraction
**File-based Metadata**
- **EPUB**: Title, Author, Description, Publisher, Series, Series Number, ISBN, Tags, Contributors, Publish Date
- **PDF**: Basic filename extraction (can be enhanced with PDF library)
- **Other formats**: Filename as title
**Folder-based Metadata (Fallback)**
- Extracts author from folder name
- Extracts series information from folder structure
- Detects series numbers from folder names
- Handles underscore-to-space conversion
**Enhanced Features**
- **Priority**: File metadata > Folder structure metadata > Filename fallback
- **Subfolder watching**: Automatically watches new subdirectories
- **Real-time updates**: Processes new/modified files immediately
- **Calibre-specific support**: Reads `calibre:series` and `calibre:series_index` metadata
**PDF & Other Formats**
- Basic filename extraction
- Folder structure metadata fallback
### Scanner Operations
@@ -130,12 +197,14 @@ Bookmann includes an intelligent ebook scanner that can automatically discover a
- **Start Monitoring**: `POST /api/scanner/start` - Begin real-time monitoring for changes
- **Stop Monitoring**: `POST /api/scanner/stop` - Stop monitoring (folders remain configured)
### Real-Time Features
### Advanced Features
- **Auto-Discovery**: New ebooks added to folders are automatically detected
- **Metadata Updates**: Modified files get updated metadata
- **Duplicate Prevention**: Existing ebooks are updated, not duplicated
- **Multi-Folder Support**: Monitor multiple directories simultaneously
- **Subfolder Scanning**: Recursively scans all subdirectories
- **Smart Error Handling**: Properly handles file system errors and database issues
- **Metadata Priority**: File metadata → Folder structure → Filename fallback
- **Real-Time Detection**: Automatic discovery of new and modified ebooks
- **Duplicate Prevention**: Updates existing entries instead of creating duplicates
- **Dynamic Watching**: Automatically watches new subdirectories as they're created
## API Testing
@@ -151,34 +220,45 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
```
.
├── cmd/server/ # Application entry point
│ ├── main.go # Main server application
│ └── static/ # Static web assets (CSS, JS, images)
├── internal/
│ ├── config/ # Configuration management
│ ├── database/ # Database connection and queries
│ ├── handlers/ # HTTP handlers (auth + ebooks)
│ └── services/ # Business logic services (ebook scanner)
├── migrations/ # Database migrations
├── database/schema/ # Database schema definitions
├── templates/ # Go HTML templates with HTMX
├── bruno/ # Bruno API testing collection
├── Dockerfile # Docker build configuration
├── docker-compose.yml # Docker Compose setup
├── go.mod # Go module definition
├── go.sum # Go module checksums
├── sqlc.yaml # SQL code generation config
├── internal/database/sqlc.yaml # SQL code generation config
└── README.md
```
## 🎨 Recent Enhancements
### Major Scanner Improvements
- **Calibre Integration**: Full support for Calibre folder structures and metadata fields
- **Smart Folder Detection**: Automatically recognizes Author/Book, Author/Series/Book patterns
- **Enhanced Metadata Extraction**: EPUB parsing with Calibre-specific support (calibre:series, calibre:series_index, ISBN, tags)
- **Subfolder Scanning**: Recursive directory scanning with automatic new folder watching
- **Robust Error Handling**: Proper pgx.ErrNoRows handling and comprehensive error recovery
- **Folder-based Metadata**: Fallback metadata extraction from folder structures when file metadata is incomplete
- **Multi-format Support**: EPUB (full), PDF (basic), MOBI, AZW3, FB2, TXT file formats
### Backend Improvements
- **Server-Side Rendering**: Replaced static frontend with Go HTML templates
- **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes
- **HTMX Integration**: Dynamic interactions using HTMX for modern UX
- **Enhanced Security**: JWT authentication with theme persistence
- **Enhanced Security**: JWT authentication with proper error handling
- **User Profile Management**: Full CRUD operations for user profiles, usernames, emails, passwords, and account deletion
- **Multiple Folder Support**: Users can configure multiple ebook directories
- **Smart Ebook Scanner**: Automatic discovery and metadata extraction from EPUB files
- **Real-Time Monitoring**: File system watching for automatic ebook updates
- **Multiple Folder Support**: Users can configure multiple ebook directories with per-user folder management
- **Real-Time Monitoring**: File system watching for automatic ebook updates with dynamic subfolder detection
- **Scan Settings**: User-configurable scan frequency and auto-scan options
- **Rating System**: User-specific ebook ratings with full CRUD operations
### Frontend Redesign
- **Beautiful Homepage**: Hero section with features showcase and modern design
@@ -192,10 +272,11 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
- **Go Templates**: Server-side rendering with template inheritance
- **Tailwind CSS**: Utility-first CSS framework via CDN
- **TypeScript Support**: Client-side scripting with TypeScript compilation
- **Database Schema**: Added user names (first_name, last_name) and user_ebook_folders table for enhanced user profiles and multiple folder support
- **API Expansion**: New endpoints for user profile management, folder management, scanner operations, and scan settings
- **Metadata Extraction**: EPUB parsing for rich ebook information
- **File System Monitoring**: Real-time folder watching with fsnotify
- **Database Schema**: Enhanced with user profiles, user_ebook_folders, ebook_ratings tables
- **API Expansion**: Comprehensive endpoints for user management, folder operations, scanner controls, and ratings
- **Advanced Metadata**: Rich ebook information extraction with fallback strategies
- **File System Monitoring**: Real-time folder watching with automatic new directory detection
- **Error Recovery**: Robust database error handling with proper pgx integration
## License
+6 -11
View File
@@ -10,15 +10,6 @@ post {
auth: inherit
}
body {
{
"folder_paths": [
"/path/to/ebooks",
"/another/path/to/ebooks"
]
}
}
settings {
encodeUrl: true
timeout: 0
@@ -27,7 +18,7 @@ settings {
docs {
## Scan Ebooks
Manually scans the specified folders for ebooks and adds them to the database.
Scans the user's configured ebook folders for ebooks and adds them to the database.
**Method:** POST
@@ -36,7 +27,7 @@ docs {
**Authentication:** Required
**Request Body:**
- `folder_paths` (array of strings, required): Array of folder paths to scan
- `folder_paths` (array of strings, optional): Array of folder paths to scan. If not provided, uses user's saved folders from Add Ebook Folder.
**Response:**
- `message` (string): Success message
@@ -45,4 +36,8 @@ docs {
- 200: Success
- 400: Bad Request
- 401: Unauthorized
**Usage:**
- Without body: Scans all folders added via "Add Ebook Folder"
- With folder_paths: Scans the specified paths directly (useful for testing)
}
+1 -1
View File
@@ -12,7 +12,7 @@ post {
body:json {
{
"folder_path": "/home/user/ebooks"
"folder_path": "/app/uploads"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ delete {
body:json {
{
"folder_path": "/home/user/ebooks"
"folder_path": "/app/uploads"
}
}
+3
View File
@@ -84,6 +84,9 @@ func main() {
authGroup.PUT("/theme", authHandler.UpdateTheme)
// force rebuild
// Static files
e.Static("/static", "static")
// Routes
handlers.SetupRoutes(protected, queries)
@@ -0,0 +1,15 @@
-- Normalize existing folder paths in user_ebook_folders table
-- This script will clean up inconsistent path formatting
UPDATE user_ebook_folders
SET folder_path = REPLACE(REPLACE(folder_path, '\\', '/'), '//', '/')
WHERE folder_path LIKE '%\\%' OR folder_path LIKE '%//%';
-- Remove trailing slashes from non-root paths
UPDATE user_ebook_folders
SET folder_path = CASE
WHEN folder_path = '/' THEN '/'
WHEN RIGHT(folder_path, 1) = '/' THEN LEFT(folder_path, LENGTH(folder_path) - 1)
ELSE folder_path
END
WHERE folder_path != '/' AND RIGHT(folder_path, 1) = '/';
+1 -1
View File
@@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: ${DBPASS}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
- ./database/schema:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
healthcheck:
+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;
+1 -1
View File
@@ -1,7 +1,7 @@
version: "2"
sql:
- engine: "postgresql"
schema: "migrations"
schema: "database/schema"
queries: "internal/database/queries"
gen:
go:
+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 {
+202 -13
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,36 +142,138 @@ 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
@@ -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) {
// 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
+18
View File
@@ -0,0 +1,18 @@
{
"name": "bookmann",
"version": "1.0.0",
"description": "A self-hosted ebook management system",
"scripts": {
"build:css": "tailwindcss -i ./cmd/server/static/input.css -o ./cmd/server/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./cmd/server/static/input.css -o ./cmd/server/static/style.css --minify",
"postinstall": "mkdir -p cmd/server/static && curl -o cmd/server/static/htmx.min.js https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"@tailwindcss/forms": "^0.5.7"
},
"scripts": {
"postinstall": "mkdir -p cmd/server/static && npx htmx-org@1.9.10 dist/htmx.min.js -o cmd/server/static/htmx.min.js"
}
}
-9
View File
@@ -1,9 +0,0 @@
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="300" fill="#f3f4f6"/>
<rect x="20" y="20" width="160" height="260" fill="#ffffff" stroke="#e5e7eb" stroke-width="2"/>
<text x="100" y="120" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Book Cover</text>
<line x1="40" y1="160" x2="160" y2="160" stroke="#e5e7eb" stroke-width="1"/>
<line x1="40" y1="180" x2="160" y2="180" stroke="#e5e7eb" stroke-width="1"/>
<line x1="40" y1="200" x2="160" y2="200" stroke="#e5e7eb" stroke-width="1"/>
<line x1="40" y1="220" x2="160" y2="220" stroke="#e5e7eb" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 661 B

+36
View File
@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./templates/**/*.{templ,html}",
"./cmd/server/static/**/*.{html,js}"
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#7aa2f7',
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
// Add your custom theme colors
'bg-primary': '#1a1b26',
'bg-secondary': '#16161e',
'text-primary': '#a9b1d6',
'text-secondary': '#565f89',
'accent': '#7aa2f7',
'border': '#414868',
}
}
},
plugins: [
require('@tailwindcss/forms'),
],
}
+2 -1
View File
@@ -6,7 +6,8 @@ templ Admin(user User) {
<head>
<meta charset="UTF-8">
<title>Admin Dashboard - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -1
View File
@@ -6,7 +6,8 @@ templ AdminLibrary(user User) {
<head>
<meta charset="UTF-8">
<title>Library Management - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -1
View File
@@ -6,7 +6,8 @@ templ AdminProfile(user User) {
<head>
<meta charset="UTF-8">
<title>Profile Settings - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Dashboard(user User) {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Index(loggedIn bool) {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookmann - Home</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Login() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Register() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style>
:root {
--bg-primary: #1a1b26;