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:
1 parent
8a951fb242
commit
08e80ae84b
34 files changed
+532
-104
No files matched your search
@@ -205,3 +205,5 @@ server
|
|||||||
|
|
||||||
# Generated templ files
|
# Generated templ files
|
||||||
templates/*_templ.go
|
templates/*_templ.go
|
||||||
|
|
||||||
|
uploads/
|
||||||
+16
-2
@@ -15,12 +15,26 @@ COPY . .
|
|||||||
# Download dependencies
|
# Download dependencies
|
||||||
RUN go mod tidy
|
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
|
# Generate sqlc code
|
||||||
RUN sqlc generate
|
RUN sqlc generate
|
||||||
|
|
||||||
# Generate templ code
|
# Generate templ code
|
||||||
RUN templ generate
|
RUN templ generate
|
||||||
|
|
||||||
|
# Build Tailwind CSS and download htmx
|
||||||
|
RUN npm run build:css:prod
|
||||||
|
RUN npm run postinstall
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
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 the binary from builder stage
|
||||||
COPY --from=builder /app/main .
|
COPY --from=builder /app/main .
|
||||||
|
|
||||||
# Copy migrations (if needed for initialization)
|
# Copy database schema (if needed for initialization)
|
||||||
COPY --from=builder /app/migrations ./migrations
|
COPY --from=builder /app/database/schema ./database/schema
|
||||||
|
|
||||||
# Copy templates
|
# Copy templates
|
||||||
COPY --from=builder /app/templates ./templates
|
COPY --from=builder /app/templates ./templates
|
||||||
|
|||||||
@@ -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
|
- **🌙 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
|
- **🎨 Theme Persistence**: User theme choices sync between browser and server
|
||||||
- **📖 Reading Progress**: User-specific reading progress tracking
|
- **📖 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
|
- **📁 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
|
- **👀 Real-Time Monitoring**: File system monitoring for automatic ebook detection and updates
|
||||||
- **📚 Rich Metadata**: Automatic extraction of ebook metadata from EPUB files
|
- **📚 Rich Metadata**: Automatic extraction of ebook metadata (title, author, description, publisher, series, ISBN, tags) from EPUB files with Calibre-specific support
|
||||||
- **🔧 RESTful API**: Clean API endpoints with JWT authentication
|
- **🏛️ 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
|
- **🐳 Docker Ready**: Single-container deployment with PostgreSQL
|
||||||
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
|
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
|
||||||
- **📱 Responsive Design**: Mobile-first responsive interface using Tailwind CSS
|
- **📱 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
|
- `GET /api/ebooks/:id/ratings` - Get all ratings for ebook
|
||||||
|
|
||||||
### Scanner (Protected)
|
### 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/start` - Start real-time monitoring of configured folders
|
||||||
- `POST /api/scanner/stop` - Stop real-time folder monitoring
|
- `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
|
### Setting Up Folders
|
||||||
|
|
||||||
1. **Add Folders**: Use the API or Bruno to add ebook folders to your account
|
1. **Add Folders**: Use `POST /api/auth/ebook-folders` to add ebook folders to your account
|
||||||
2. **Supported Formats**: EPUB, PDF, MOBI, AZW3, FB2, TXT
|
2. **Supported Formats**: EPUB (full metadata), PDF (basic), MOBI, AZW3, FB2, TXT
|
||||||
3. **Metadata Extraction**: EPUB files automatically get rich metadata (title, author, description, publisher, etc.)
|
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
|
### 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
|
- **Start Monitoring**: `POST /api/scanner/start` - Begin real-time monitoring for changes
|
||||||
- **Stop Monitoring**: `POST /api/scanner/stop` - Stop monitoring (folders remain configured)
|
- **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
|
- **Subfolder Scanning**: Recursively scans all subdirectories
|
||||||
- **Metadata Updates**: Modified files get updated metadata
|
- **Smart Error Handling**: Properly handles file system errors and database issues
|
||||||
- **Duplicate Prevention**: Existing ebooks are updated, not duplicated
|
- **Metadata Priority**: File metadata → Folder structure → Filename fallback
|
||||||
- **Multi-Folder Support**: Monitor multiple directories simultaneously
|
- **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
|
## API Testing
|
||||||
|
|
||||||
@@ -151,34 +220,45 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
|
|||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── cmd/server/ # Application entry point
|
├── cmd/server/ # Application entry point
|
||||||
|
│ ├── main.go # Main server application
|
||||||
|
│ └── static/ # Static web assets (CSS, JS, images)
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # Configuration management
|
│ ├── config/ # Configuration management
|
||||||
│ ├── database/ # Database connection and queries
|
│ ├── database/ # Database connection and queries
|
||||||
│ ├── handlers/ # HTTP handlers (auth + ebooks)
|
│ ├── handlers/ # HTTP handlers (auth + ebooks)
|
||||||
│ └── services/ # Business logic services (ebook scanner)
|
│ └── services/ # Business logic services (ebook scanner)
|
||||||
├── migrations/ # Database migrations
|
├── database/schema/ # Database schema definitions
|
||||||
├── templates/ # Go HTML templates with HTMX
|
├── templates/ # Go HTML templates with HTMX
|
||||||
├── bruno/ # Bruno API testing collection
|
├── bruno/ # Bruno API testing collection
|
||||||
├── Dockerfile # Docker build configuration
|
├── Dockerfile # Docker build configuration
|
||||||
├── docker-compose.yml # Docker Compose setup
|
├── docker-compose.yml # Docker Compose setup
|
||||||
├── go.mod # Go module definition
|
├── go.mod # Go module definition
|
||||||
├── go.sum # Go module checksums
|
├── go.sum # Go module checksums
|
||||||
├── sqlc.yaml # SQL code generation config
|
├── internal/database/sqlc.yaml # SQL code generation config
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎨 Recent Enhancements
|
## 🎨 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
|
### Backend Improvements
|
||||||
- **Server-Side Rendering**: Replaced static frontend with Go HTML templates
|
- **Server-Side Rendering**: Replaced static frontend with Go HTML templates
|
||||||
- **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes
|
- **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes
|
||||||
- **HTMX Integration**: Dynamic interactions using HTMX for modern UX
|
- **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
|
- **User Profile Management**: Full CRUD operations for user profiles, usernames, emails, passwords, and account deletion
|
||||||
- **Multiple Folder Support**: Users can configure multiple ebook directories
|
- **Multiple Folder Support**: Users can configure multiple ebook directories with per-user folder management
|
||||||
- **Smart Ebook Scanner**: Automatic discovery and metadata extraction from EPUB files
|
- **Real-Time Monitoring**: File system watching for automatic ebook updates with dynamic subfolder detection
|
||||||
- **Real-Time Monitoring**: File system watching for automatic ebook updates
|
|
||||||
- **Scan Settings**: User-configurable scan frequency and auto-scan options
|
- **Scan Settings**: User-configurable scan frequency and auto-scan options
|
||||||
|
- **Rating System**: User-specific ebook ratings with full CRUD operations
|
||||||
|
|
||||||
### Frontend Redesign
|
### Frontend Redesign
|
||||||
- **Beautiful Homepage**: Hero section with features showcase and modern design
|
- **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
|
- **Go Templates**: Server-side rendering with template inheritance
|
||||||
- **Tailwind CSS**: Utility-first CSS framework via CDN
|
- **Tailwind CSS**: Utility-first CSS framework via CDN
|
||||||
- **TypeScript Support**: Client-side scripting with TypeScript compilation
|
- **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
|
- **Database Schema**: Enhanced with user profiles, user_ebook_folders, ebook_ratings tables
|
||||||
- **API Expansion**: New endpoints for user profile management, folder management, scanner operations, and scan settings
|
- **API Expansion**: Comprehensive endpoints for user management, folder operations, scanner controls, and ratings
|
||||||
- **Metadata Extraction**: EPUB parsing for rich ebook information
|
- **Advanced Metadata**: Rich ebook information extraction with fallback strategies
|
||||||
- **File System Monitoring**: Real-time folder watching with fsnotify
|
- **File System Monitoring**: Real-time folder watching with automatic new directory detection
|
||||||
|
- **Error Recovery**: Robust database error handling with proper pgx integration
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ post {
|
|||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
{
|
|
||||||
"folder_paths": [
|
|
||||||
"/path/to/ebooks",
|
|
||||||
"/another/path/to/ebooks"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
@@ -26,23 +17,27 @@ settings {
|
|||||||
|
|
||||||
docs {
|
docs {
|
||||||
## Scan Ebooks
|
## 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
|
**Method:** POST
|
||||||
|
|
||||||
**Endpoint:** /api/scanner/scan
|
**Endpoint:** /api/scanner/scan
|
||||||
|
|
||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Request Body:**
|
**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:**
|
**Response:**
|
||||||
- `message` (string): Success message
|
- `message` (string): Success message
|
||||||
|
|
||||||
**Status Codes:**
|
**Status Codes:**
|
||||||
- 200: Success
|
- 200: Success
|
||||||
- 400: Bad Request
|
- 400: Bad Request
|
||||||
- 401: Unauthorized
|
- 401: Unauthorized
|
||||||
}
|
|
||||||
|
**Usage:**
|
||||||
|
- Without body: Scans all folders added via "Add Ebook Folder"
|
||||||
|
- With folder_paths: Scans the specified paths directly (useful for testing)
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"folder_path": "/home/user/ebooks"
|
"folder_path": "/app/uploads"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,4 +46,4 @@ docs {
|
|||||||
- 400: Invalid request
|
- 400: Invalid request
|
||||||
- 401: Unauthorized
|
- 401: Unauthorized
|
||||||
- 409: Folder already exists
|
- 409: Folder already exists
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ delete {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"folder_path": "/home/user/ebooks"
|
"folder_path": "/app/uploads"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +43,4 @@ docs {
|
|||||||
- 400: Invalid request
|
- 400: Invalid request
|
||||||
- 401: Unauthorized
|
- 401: Unauthorized
|
||||||
- 500: Internal server error
|
- 500: Internal server error
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,9 @@ func main() {
|
|||||||
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
||||||
// force rebuild
|
// force rebuild
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
e.Static("/static", "static")
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
handlers.SetupRoutes(protected, queries)
|
handlers.SetupRoutes(protected, queries)
|
||||||
|
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
@@ -10,7 +10,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DBPASS}
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./migrations:/docker-entrypoint-initdb.d
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Querier interface {
|
|||||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) 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)
|
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :one
|
||||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING id, user_id, folder_path, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type DeleteUserEbookFolderParams struct {
|
type DeleteUserEbookFolderParams struct {
|
||||||
@@ -232,9 +232,16 @@ type DeleteUserEbookFolderParams struct {
|
|||||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error {
|
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error) {
|
||||||
_, err := q.db.Exec(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
row := q.db.QueryRow(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||||
return err
|
var i UserEbookFolders
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.FolderPath,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetEbook = `-- name: GetEbook :one
|
const GetEbook = `-- name: GetEbook :one
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING
|
|||||||
-- name: GetUserEbookFolders :many
|
-- name: GetUserEbookFolders :many
|
||||||
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
|
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
|
||||||
|
|
||||||
-- name: DeleteUserEbookFolder :exec
|
-- name: DeleteUserEbookFolder :one
|
||||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2;
|
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING *;
|
||||||
|
|
||||||
-- name: GetEbookByFilePath :one
|
-- name: GetEbookByFilePath :one
|
||||||
SELECT * FROM ebooks WHERE file_path = $1;
|
SELECT * FROM ebooks WHERE file_path = $1;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- engine: "postgresql"
|
- engine: "postgresql"
|
||||||
schema: "migrations"
|
schema: "database/schema"
|
||||||
queries: "internal/database/queries"
|
queries: "internal/database/queries"
|
||||||
gen:
|
gen:
|
||||||
go:
|
go:
|
||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"bookmann/internal/database"
|
"bookmann/internal/database"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
jwt "github.com/golang-jwt/jwt/v5"
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -370,6 +373,31 @@ type AddEbookFolderRequest struct {
|
|||||||
FolderPath string `json:"folder_path" validate:"required"`
|
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 {
|
type EbookFolderResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_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()})
|
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{
|
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
FolderPath: req.FolderPath,
|
FolderPath: normalizedPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
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()})
|
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
|
var response []EbookFolderResponse
|
||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
response = append(response, EbookFolderResponse{
|
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()})
|
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},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
FolderPath: req.FolderPath,
|
FolderPath: normalizedPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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()})
|
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"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
|
|||||||
|
|
||||||
// ScanEbooksRequest represents the request for scanning ebooks
|
// ScanEbooksRequest represents the request for scanning ebooks
|
||||||
type ScanEbooksRequest struct {
|
type ScanEbooksRequest struct {
|
||||||
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
|
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanEbooks handles POST /api/scanner/scan
|
// 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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
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
|
// 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()})
|
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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
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
|
// Set the folder paths
|
||||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
epub "github.com/ArcadiaLin/go-epub"
|
epub "github.com/ArcadiaLin/go-epub"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ type EbookMetadata struct {
|
|||||||
PublishDate time.Time
|
PublishDate time.Time
|
||||||
Contributors string
|
Contributors string
|
||||||
CoverPath string
|
CoverPath string
|
||||||
|
ISBN string
|
||||||
|
Tags string
|
||||||
}
|
}
|
||||||
|
|
||||||
type EbookScanner struct {
|
type EbookScanner struct {
|
||||||
@@ -75,12 +80,28 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
|||||||
return fmt.Errorf("no folders set")
|
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 {
|
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 {
|
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("Error accessing path %s: %v\n", path, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalFiles++
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
// Also watch subdirectories
|
// Also watch subdirectories
|
||||||
if err := s.watcher.Add(path); err != nil {
|
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
|
// Check if it's an ebook file
|
||||||
if s.isEbookFile(path) {
|
if s.isEbookFile(path) {
|
||||||
|
ebookFiles++
|
||||||
|
fmt.Printf("Found ebook file: %s\n", path)
|
||||||
if err := s.processEbookFile(ctx, path); err != nil {
|
if err := s.processEbookFile(ctx, path); err != nil {
|
||||||
fmt.Printf("Error processing ebook %s: %v\n", path, err)
|
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
|
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 {
|
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
|
||||||
|
fmt.Printf("Processing ebook file: %s\n", path)
|
||||||
|
|
||||||
// Get file info
|
// Get file info
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
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)
|
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
|
// Check if ebook already exists in database
|
||||||
existingEbook, err := s.getEbookByFilePath(ctx, path)
|
existingEbook, err := s.getEbookByFilePath(ctx, path)
|
||||||
if err == nil {
|
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)
|
// Ebook exists, check if file has changed (by size)
|
||||||
if existingEbook.FileSize.Int64 != info.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)
|
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
|
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
|
// Some other error occurred
|
||||||
return fmt.Errorf("failed to check if ebook exists: %v", err)
|
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)
|
metadata, err := s.extractMetadata(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
|
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
|
||||||
// Continue with basic metadata
|
metadata = &EbookMetadata{}
|
||||||
metadata = &EbookMetadata{
|
}
|
||||||
Title: filepath.Base(path),
|
|
||||||
Author: "Unknown",
|
// 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
|
// Create ebook in database
|
||||||
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
|
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
|
||||||
Title: metadata.Title,
|
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 != ""},
|
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||||
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
|
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
|
||||||
|
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
|
||||||
})
|
})
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -173,10 +302,12 @@ func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
|
|||||||
switch ext {
|
switch ext {
|
||||||
case ".epub":
|
case ".epub":
|
||||||
return s.extractEPUBMetadata(path)
|
return s.extractEPUBMetadata(path)
|
||||||
|
case ".pdf":
|
||||||
|
return s.extractPDFMetadata(path)
|
||||||
default:
|
default:
|
||||||
// For other formats, return basic metadata
|
// For other formats, return basic metadata
|
||||||
return &EbookMetadata{
|
return &EbookMetadata{
|
||||||
Title: filepath.Base(path),
|
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,10 +340,25 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
|
|||||||
metadata.Publisher = publishers[0]
|
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
|
// Publish date
|
||||||
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
|
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
|
||||||
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
|
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
|
||||||
metadata.PublishDate = date
|
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, ", ")
|
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
|
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 {
|
func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error {
|
||||||
metadata, err := s.extractMetadata(filePath)
|
metadata, err := s.extractMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -237,12 +412,12 @@ func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, fil
|
|||||||
ID: ebookID,
|
ID: ebookID,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
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 != ""},
|
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
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
|
Asin: pgtype.Text{}, // Keep existing ASIN
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||||
@@ -284,14 +459,28 @@ func (s *EbookScanner) WatchChanges(ctx context.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
|
||||||
if s.isEbookFile(event.Name) {
|
// Handle new directories - add them to the watcher
|
||||||
fmt.Printf("New/modified ebook detected: %s\n", event.Name)
|
if event.Has(fsnotify.Create) {
|
||||||
if err := s.processEbookFile(ctx, event.Name); err != nil {
|
info, err := os.Stat(event.Name)
|
||||||
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err)
|
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:
|
case err, ok := <-s.watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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'),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ templ Admin(user User) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Admin Dashboard - Bookmann</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ templ AdminLibrary(user User) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Library Management - Bookmann</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ templ AdminProfile(user User) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Profile Settings - Bookmann</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ templ Dashboard(user User) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dashboard - Bookmann</title>
|
<title>Dashboard - Bookmann</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ templ Index(loggedIn bool) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Bookmann - Home</title>
|
<title>Bookmann - Home</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ templ Login() {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ templ Register() {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Register</title>
|
<title>Register</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
Reference in new issue
Block a user