From 08e80ae84bebd14374550f59acd4fad722ce5418 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Jan 2026 23:40:31 -0500 Subject: [PATCH] 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 --- .gitignore | 2 + Dockerfile | 18 +- README.md | 129 +++++++++-- bruno/scanner/Scan Ebooks.bru | 33 ++- bruno/user/Add Ebook Folder.bru | 4 +- bruno/user/Delete Ebook Folder.bru | 4 +- cmd/server/main.go | 3 + .../schema}/001_create_tables.up.sql | 0 .../schema}/002_add_ratings.up.sql | 0 .../003_add_ebook_metadata_fields.up.sql | 0 .../schema}/004_add_ebook_folder_path.up.sql | 0 .../005_create_user_ebook_folders.up.sql | 0 .../schema}/006_drop_ebook_folder_path.up.sql | 0 .../schema}/007_add_scan_settings.up.sql | 0 .../schema}/008_add_user_names.up.sql | 0 database/schema/normalize_folder_paths.sql | 15 ++ docker-compose.yml | 2 +- internal/database/querier.go | 2 +- internal/database/queries.sql.go | 17 +- internal/database/queries/queries.sql | 4 +- sqlc.yaml => internal/database/sqlc.yaml | 2 +- internal/handlers/auth.go | 55 ++++- internal/handlers/ebook.go | 39 +++- internal/services/ebook_scanner.go | 219 ++++++++++++++++-- package.json | 18 ++ static/placeholder-book.svg | 9 - tailwind.config.js | 36 +++ templates/admin.templ | 3 +- templates/admin_library.templ | 3 +- templates/admin_profile.templ | 3 +- templates/dashboard.templ | 4 +- templates/index.templ | 4 +- templates/login.templ | 4 +- templates/register.templ | 4 +- 34 files changed, 532 insertions(+), 104 deletions(-) rename {migrations => database/schema}/001_create_tables.up.sql (100%) rename {migrations => database/schema}/002_add_ratings.up.sql (100%) rename {migrations => database/schema}/003_add_ebook_metadata_fields.up.sql (100%) rename {migrations => database/schema}/004_add_ebook_folder_path.up.sql (100%) rename {migrations => database/schema}/005_create_user_ebook_folders.up.sql (100%) rename {migrations => database/schema}/006_drop_ebook_folder_path.up.sql (100%) rename {migrations => database/schema}/007_add_scan_settings.up.sql (100%) rename {migrations => database/schema}/008_add_user_names.up.sql (100%) create mode 100644 database/schema/normalize_folder_paths.sql rename sqlc.yaml => internal/database/sqlc.yaml (93%) create mode 100644 package.json delete mode 100644 static/placeholder-book.svg create mode 100644 tailwind.config.js diff --git a/.gitignore b/.gitignore index dc8d9c3..79f4018 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,5 @@ server # Generated templ files templates/*_templ.go + +uploads/ diff --git a/Dockerfile b/Dockerfile index 8909958..60a31a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 3fdc9a0..9821682 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bruno/scanner/Scan Ebooks.bru b/bruno/scanner/Scan Ebooks.bru index 0af2865..03afcb8 100644 --- a/bruno/scanner/Scan Ebooks.bru +++ b/bruno/scanner/Scan Ebooks.bru @@ -10,15 +10,6 @@ post { auth: inherit } -body { - { - "folder_paths": [ - "/path/to/ebooks", - "/another/path/to/ebooks" - ] - } -} - settings { encodeUrl: true timeout: 0 @@ -26,23 +17,27 @@ 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 - + **Endpoint:** /api/scanner/scan - + **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 - + **Status Codes:** - 200: Success - 400: Bad Request - 401: Unauthorized -} \ No newline at end of file + + **Usage:** + - Without body: Scans all folders added via "Add Ebook Folder" + - With folder_paths: Scans the specified paths directly (useful for testing) +} diff --git a/bruno/user/Add Ebook Folder.bru b/bruno/user/Add Ebook Folder.bru index 5158313..28b9ef6 100644 --- a/bruno/user/Add Ebook Folder.bru +++ b/bruno/user/Add Ebook Folder.bru @@ -12,7 +12,7 @@ post { body:json { { - "folder_path": "/home/user/ebooks" + "folder_path": "/app/uploads" } } @@ -46,4 +46,4 @@ docs { - 400: Invalid request - 401: Unauthorized - 409: Folder already exists -} \ No newline at end of file +} diff --git a/bruno/user/Delete Ebook Folder.bru b/bruno/user/Delete Ebook Folder.bru index 76549fa..ee9c1b5 100644 --- a/bruno/user/Delete Ebook Folder.bru +++ b/bruno/user/Delete Ebook Folder.bru @@ -12,7 +12,7 @@ delete { body:json { { - "folder_path": "/home/user/ebooks" + "folder_path": "/app/uploads" } } @@ -43,4 +43,4 @@ docs { - 400: Invalid request - 401: Unauthorized - 500: Internal server error -} \ No newline at end of file +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f84e9a4..8242f8a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -84,6 +84,9 @@ func main() { authGroup.PUT("/theme", authHandler.UpdateTheme) // force rebuild + // Static files + e.Static("/static", "static") + // Routes handlers.SetupRoutes(protected, queries) diff --git a/migrations/001_create_tables.up.sql b/database/schema/001_create_tables.up.sql similarity index 100% rename from migrations/001_create_tables.up.sql rename to database/schema/001_create_tables.up.sql diff --git a/migrations/002_add_ratings.up.sql b/database/schema/002_add_ratings.up.sql similarity index 100% rename from migrations/002_add_ratings.up.sql rename to database/schema/002_add_ratings.up.sql diff --git a/migrations/003_add_ebook_metadata_fields.up.sql b/database/schema/003_add_ebook_metadata_fields.up.sql similarity index 100% rename from migrations/003_add_ebook_metadata_fields.up.sql rename to database/schema/003_add_ebook_metadata_fields.up.sql diff --git a/migrations/004_add_ebook_folder_path.up.sql b/database/schema/004_add_ebook_folder_path.up.sql similarity index 100% rename from migrations/004_add_ebook_folder_path.up.sql rename to database/schema/004_add_ebook_folder_path.up.sql diff --git a/migrations/005_create_user_ebook_folders.up.sql b/database/schema/005_create_user_ebook_folders.up.sql similarity index 100% rename from migrations/005_create_user_ebook_folders.up.sql rename to database/schema/005_create_user_ebook_folders.up.sql diff --git a/migrations/006_drop_ebook_folder_path.up.sql b/database/schema/006_drop_ebook_folder_path.up.sql similarity index 100% rename from migrations/006_drop_ebook_folder_path.up.sql rename to database/schema/006_drop_ebook_folder_path.up.sql diff --git a/migrations/007_add_scan_settings.up.sql b/database/schema/007_add_scan_settings.up.sql similarity index 100% rename from migrations/007_add_scan_settings.up.sql rename to database/schema/007_add_scan_settings.up.sql diff --git a/migrations/008_add_user_names.up.sql b/database/schema/008_add_user_names.up.sql similarity index 100% rename from migrations/008_add_user_names.up.sql rename to database/schema/008_add_user_names.up.sql diff --git a/database/schema/normalize_folder_paths.sql b/database/schema/normalize_folder_paths.sql new file mode 100644 index 0000000..bf63b68 --- /dev/null +++ b/database/schema/normalize_folder_paths.sql @@ -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) = '/'; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 526b9ac..e80e4b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/internal/database/querier.go b/internal/database/querier.go index f92b262..cf15217 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index cbb3e4d..8ebae8e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 6bd875a..ad74472 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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; \ No newline at end of file diff --git a/sqlc.yaml b/internal/database/sqlc.yaml similarity index 93% rename from sqlc.yaml rename to internal/database/sqlc.yaml index 7bb1624..ac2c05f 100644 --- a/sqlc.yaml +++ b/internal/database/sqlc.yaml @@ -1,7 +1,7 @@ version: "2" sql: - engine: "postgresql" - schema: "migrations" + schema: "database/schema" queries: "internal/database/queries" gen: go: diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index c7feb38..f248639 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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"}) } diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index 4298973..118959d 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -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 { diff --git a/internal/services/ebook_scanner.go b/internal/services/ebook_scanner.go index 62b183e..54e1da1 100644 --- a/internal/services/ebook_scanner.go +++ b/internal/services/ebook_scanner.go @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..46fce86 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/static/placeholder-book.svg b/static/placeholder-book.svg deleted file mode 100644 index 6544b09..0000000 --- a/static/placeholder-book.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - Book Cover - - - - - \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..35a6ee9 --- /dev/null +++ b/tailwind.config.js @@ -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'), + ], +} \ No newline at end of file diff --git a/templates/admin.templ b/templates/admin.templ index 68d6de4..328d74f 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -6,7 +6,8 @@ templ Admin(user User) { Admin Dashboard - Bookmann - + +