diff --git a/docker-compose.yml b/docker-compose.yml index 07e73c6..84da172 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: postgres: image: postgres volumes: - - pg-data:/var/lib/postgresql/data + - pg-data:/var/lib/postgresql environment: POSTGRES_USER: 'user' POSTGRES_PASSWORD: 'pass' @@ -18,6 +18,7 @@ services: volumes: - ./data:/data environment: + KOMPANION_BSTORAGE_TYPE: postgres KOMPANION_PG_URL: 'postgres://user:pass@postgres:5432/postgres' KOMPANION_BSTORAGE_PATH: '/data/books/' KOMPANION_AUTH_USERNAME: 'user' @@ -28,4 +29,4 @@ services: - postgres volumes: - pg-data: + pg-data: \ No newline at end of file diff --git a/internal/controller/http/web/books.go b/internal/controller/http/web/books.go index e9aae9a..dfb1b17 100644 --- a/internal/controller/http/web/books.go +++ b/internal/controller/http/web/books.go @@ -29,6 +29,7 @@ func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, stats stats.R handler.POST("/:bookID", r.updateBookMetadata) handler.GET("/:bookID/download", r.downloadBook) handler.GET("/:bookID/cover", r.viewBookCover) + handler.POST("/:bookID/delete", r.deleteBook) } func (r *booksRoutes) listBooks(c *gin.Context) { @@ -203,3 +204,16 @@ func (r *booksRoutes) viewBookCover(c *gin.Context) { } c.File(cover.Name()) } + +func (r *booksRoutes) deleteBook(c *gin.Context) { + bookID := c.Param("bookID") + + err := r.shelf.DeleteBook(c, bookID) + if err != nil { + r.logger.Error(err, "http - v1 - shelf - deleteBook") + c.JSON(500, passStandartContext(c, gin.H{"message": "internal server error"})) + return + } + + c.Redirect(302, "/books") +} \ No newline at end of file diff --git a/internal/library/book_postgres.go b/internal/library/book_postgres.go index aa665d9..c3571e1 100644 --- a/internal/library/book_postgres.go +++ b/internal/library/book_postgres.go @@ -69,6 +69,27 @@ func (bdr *BookDatabaseRepo) Update(ctx context.Context, book entity.Book) error return nil } +// Delete -. only delete from database +func (bdr *BookDatabaseRepo) Delete(ctx context.Context, book entity.Book) error { + sql := ` + DELETE FROM library_book + WHERE id = $1 + ` + + args := []interface{}{ + book.ID, + } + + rows, err := bdr.Pool.Exec(ctx, sql, args...) + if err != nil { + return fmt.Errorf("BookDatabaseRepo - Delete - r.Pool.Exec: %w", err) + } + if rows.RowsAffected() == 0 { + return fmt.Errorf("BookDatabaseRepo - Delete - no rows affected") + } + return nil +} + // List -. only select from database func (bdr *BookDatabaseRepo) List(ctx context.Context, sortBy, sortOrder string, diff --git a/internal/library/interfaces.go b/internal/library/interfaces.go index 52838ab..f7e85a1 100644 --- a/internal/library/interfaces.go +++ b/internal/library/interfaces.go @@ -19,6 +19,7 @@ type ( DownloadBook(ctx context.Context, bookID string) (entity.Book, *os.File, error) UpdateBookMetadata(ctx context.Context, bookID string, metadata entity.Book) (entity.Book, error) ViewCover(ctx context.Context, bookID string) (*os.File, error) + DeleteBook(ctx context.Context, bookID string) error } // BookRepo -. @@ -32,5 +33,6 @@ type ( GetById(context.Context, string) (entity.Book, error) GetByFileHash(context.Context, string) (entity.Book, error) Update(context.Context, entity.Book) error + Delete(context.Context, entity.Book) error } ) diff --git a/internal/library/shelf.go b/internal/library/shelf.go index b9bdff2..af02f89 100644 --- a/internal/library/shelf.go +++ b/internal/library/shelf.go @@ -145,6 +145,30 @@ func (uc *BookShelf) UpdateBookMetadata(ctx context.Context, bookID string, meta return updatedBook, nil } +func (uc *BookShelf) DeleteBook(ctx context.Context, bookID string) error { + book, err := uc.repo.GetById(ctx, bookID) + if err != nil { + return fmt.Errorf("BookShelf - DeleteBook - s.repo.Get: %w", err) + } + + err = uc.deleteCover(ctx, bookID) + if err != nil { + return fmt.Errorf("BookShelf - deleteCover - s.repo.Delete: %w", err) + } + + err = uc.repo.Delete(ctx, book) + if err != nil { + return fmt.Errorf("BookShelf - DeleteBook - s.repo.Delete: %w", err) + } + + err = uc.storage.Delete(ctx, book.FilePath) + if err != nil { + return fmt.Errorf("BookShelf - DeleteBook - s.storage.Delete: %w", err) + } + + return nil +} + func (uc *BookShelf) DownloadBook(ctx context.Context, bookID string) (entity.Book, *os.File, error) { book, err := uc.repo.GetById(ctx, bookID) if err != nil { @@ -198,3 +222,18 @@ func writeCover( } return coverpath, nil } + +func (uc *BookShelf) deleteCover(ctx context.Context, bookID string) error { + book, err := uc.repo.GetById(ctx, bookID) + if err != nil { + return fmt.Errorf("BookShelf - deleteCover - s.repo.Get: %w", err) + } + if book.CoverPath == "" { + return nil + } + err = uc.storage.Delete(ctx, book.CoverPath) + if err != nil { + return fmt.Errorf("BookShelf - deleteCover - s.Storage.Delete: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index abde80a..5f6ecd6 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -75,6 +75,15 @@ func (s *FilesystemStorage) Write(ctx context.Context, src, dest string) error { return nil } +func (s *FilesystemStorage) Delete(ctx context.Context, p string) error { + filepath := path.Join(s.root, p) + err := os.Remove(filepath) + if err != nil { + return err + } + return nil +} + func checkSystemWrites(root string) error { // Create a temporary file in the root directory tempFile, err := os.CreateTemp(root, "write_test") diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index ac9b3f5..0baacef 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -42,6 +42,7 @@ func TestFilesystemStorage(t *testing.T) { if err != nil { t.Errorf("Error reading file: %v", err) } + defer readFile.Close() readBody, err := os.ReadFile(readFile.Name()) if err != nil { @@ -50,4 +51,15 @@ func TestFilesystemStorage(t *testing.T) { if string(readBody) != string(body) { t.Errorf("Expected body %s, got %s", string(body), string(readBody)) } + + err = st.Delete(ctx, "test") + if err != nil { + t.Errorf("Error deleting file: %v", err) + } + + defer readFile.Close() + _, err =os.ReadFile(readFile.Name()) + if err == nil { + t.Errorf("Error deleting file failed") + } } diff --git a/internal/storage/interfaces.go b/internal/storage/interfaces.go index 0426bbe..515d866 100644 --- a/internal/storage/interfaces.go +++ b/internal/storage/interfaces.go @@ -8,4 +8,5 @@ import ( type Storage interface { Write(ctx context.Context, source string, filepath string) error Read(ctx context.Context, filepath string) (*os.File, error) -} + Delete(ctx context.Context, filepath string) error +} \ No newline at end of file diff --git a/internal/storage/memory.go b/internal/storage/memory.go index 39499dd..815f539 100644 --- a/internal/storage/memory.go +++ b/internal/storage/memory.go @@ -53,3 +53,10 @@ func (s *MemoryStorage) Write(ctx context.Context, source string, filepath strin s.mu.Unlock() return nil } + +func (s *MemoryStorage) Delete(ctx context.Context, filepath string) error { + s.mu.Lock() + s.data[filepath] = nil + s.mu.Unlock() + return nil +} \ No newline at end of file diff --git a/internal/storage/postgres.go b/internal/storage/postgres.go index 3ef33cd..56c1385 100644 --- a/internal/storage/postgres.go +++ b/internal/storage/postgres.go @@ -72,3 +72,17 @@ func (ps *PostgresStorage) Read(ctx context.Context, filepath string) (*os.File, } return tempFile, nil } + +func (ps *PostgresStorage) Delete(ctx context.Context, filepath string) error { + sql := ` + DELETE FROM storage_blob + WHERE file_path = $1 + ` + args := []interface{}{filepath} + _, err := ps.Pool.Exec(ctx, sql, args...) + if err != nil { + return fmt.Errorf("PostgresStorage - Read - r.Pool.QueryRow: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/pkg/metadata/pdf.go b/pkg/metadata/pdf.go index 38d8167..885d8b7 100644 --- a/pkg/metadata/pdf.go +++ b/pkg/metadata/pdf.go @@ -1,9 +1,14 @@ package metadata import ( - "bufio" "os" + "bytes" + "unicode/utf8" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" + "regexp" "strings" + "io" ) // PDFMetadata holds the extracted PDFmetadata information @@ -16,23 +21,35 @@ type PDFMetadata struct { // extractPDFMetadataFromHeader scans the first part of the PDF file for PDFmetadata information func extractPdfMetadata(tmpFile *os.File) (Metadata, error) { - scanner := bufio.NewScanner(tmpFile) var PDFmetadata Metadata - for scanner.Scan() { - line := scanner.Text() - if strings.Contains(line, "/Title") { - PDFmetadata.Title = extractValue(line, "/Title") + const chunksize = 64 * 1024 + const tailsize = 1024 + + buf := make([]byte, chunksize) + tail := make([]byte, tailsize) + + for { + n, err := tmpFile.Read(buf) + + block := append(tail, buf[:n]...) + + if bytes.Contains(block, []byte("/Title")) { + PDFmetadata.Title = clean(extractValue(block, "/Title")) + + } + + if bytes.Contains(block, []byte("/Author")) { + PDFmetadata.Author = clean(extractValue(block, "/Author")) } - if strings.Contains(line, "/Author") { - PDFmetadata.Author = extractValue(line, "/Author") + + if err == io.EOF { + break + } + + if err != nil { + return Metadata{}, err } - // if strings.Contains(line, "/Subject") { - // PDFmetadata.Subject = extractValue(line, "/Subject") - // } - // if strings.Contains(line, "/Keywords") { - // PDFmetadata.Keywords = extractValue(line, "/Keywords") - // } // Break early if we've found all fields if PDFmetadata.Title != "" && PDFmetadata.Author != "" { @@ -40,23 +57,92 @@ func extractPdfMetadata(tmpFile *os.File) (Metadata, error) { } } - if err := scanner.Err(); err != nil { - return Metadata{}, err + // use file name if no title found + if PDFmetadata.Title == "" { + parts := strings.Split(tmpFile.Name(), "/") + parts = strings.Split(parts[len(parts) - 1], ".") + PDFmetadata.Title = parts[0] } return PDFmetadata, nil } // extractValue extracts the value for a specific metadata field -func extractValue(line string, field string) string { - start := strings.Index(line, field+"(") - if start == -1 { - return "" +func extractValue(block []byte, field string) string { + fieldIndex := bytes.Index(block, []byte(field)) + start := bytes.Index(block[fieldIndex:], []byte("(")) + fieldIndex + 1 + end := findLiteralEnd(block[start:]) + start + value := block[start:end] + + if utf8.Valid(value) { + return string(value) + } + + // remove escape symbol + regex := regexp.MustCompile(`\\(.)`) + value = regex.ReplaceAll(value, []byte("$1")) + + // UTF16BE + if len(value) >= 2 && value[0] == 0xFE && value[1] == 0xFF { + return decodeUTF16("be", value) + } + // UTF16LE + if len(value) >= 2 && value[0] == 0xFF && value[1] == 0xFE { + return decodeUTF16("le", value) + } + + return "" +} + +// find the literal end, excluding parentheis in the field +func findLiteralEnd(b []byte) int { + depth := 1 + escaped := false + for i := 0; i < len(b); i++ { + c := b[i] + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == '(' { + depth++ + continue + } + if c == ')' { + depth-- + if depth == 0 { + return i + } + } } - start += len(field) + 1 // Skip past the field and the opening parenthesis - end := strings.Index(line[start:], ")") - if end == -1 { - return "" + return -1 +} + +// decode UTF-16 content +func decodeUTF16(t string, data []byte) string { + var dec transform.Transformer + if t == "be" { + dec = unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM).NewDecoder() + } else if t == "le" { + dec = unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM).NewDecoder() } - return line[start : start+end] + + r := transform.NewReader(bytes.NewReader(data), dec) + b, _ := io.ReadAll(r) + return string(b) } + +// remove non-utf8 bytes +func clean(s string) string { + r := strings.NewReplacer( + "\x00", "", + "\xFF", "", + "\xFE", "", + ) + + return r.Replace(s) +} \ No newline at end of file diff --git a/pkg/metadata/pdf_test.go b/pkg/metadata/pdf_test.go new file mode 100644 index 0000000..76bdf43 --- /dev/null +++ b/pkg/metadata/pdf_test.go @@ -0,0 +1,48 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + + +func TestReadAllPDFBooks(t *testing.T) { + booksDir := "../../test/test_data/books" + + err := filepath.Walk(booksDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if filepath.Ext(path) == ".pdf" { + t.Logf("\nProcessing file: %s\n", path) + + file, err := os.Open(path) + if err != nil { + t.Logf(" Error: failed to open file - %v\n", err) + return nil + } + defer file.Close() + + metadata, err := extractPdfMetadata(file) + if err != nil { + t.Logf(" Error: failed to parse metadata - %v\n", err) + return nil + } + + t.Logf(" Title: %s\n", metadata.Title) + t.Logf(" Author: %s\n", metadata.Author) + } + + return nil + }) + + if err != nil { + t.Fatalf("Failed to traverse directory: %v", err) + } +} \ No newline at end of file diff --git a/test/test_data/books/PrincessOfMars-PDF.pdf b/test/test_data/books/PrincessOfMars-PDF.pdf index 0d1308d..eff9e65 100644 Binary files a/test/test_data/books/PrincessOfMars-PDF.pdf and b/test/test_data/books/PrincessOfMars-PDF.pdf differ diff --git "a/test/test_data/books/\344\270\203\346\227\245\347\213\202\351\262\250.pdf" "b/test/test_data/books/\344\270\203\346\227\245\347\213\202\351\262\250.pdf" new file mode 100644 index 0000000..49ac8e8 Binary files /dev/null and "b/test/test_data/books/\344\270\203\346\227\245\347\213\202\351\262\250.pdf" differ diff --git "a/test/test_data/books/\345\220\264\346\260\217\347\237\263\345\244\264\350\256\260\345\242\236\345\210\240\350\257\225\350\257\204\346\234\254\357\274\210\347\231\270\351\205\211\346\234\254\357\274\211.pdf" "b/test/test_data/books/\345\220\264\346\260\217\347\237\263\345\244\264\350\256\260\345\242\236\345\210\240\350\257\225\350\257\204\346\234\254\357\274\210\347\231\270\351\205\211\346\234\254\357\274\211.pdf" new file mode 100644 index 0000000..ada874f Binary files /dev/null and "b/test/test_data/books/\345\220\264\346\260\217\347\237\263\345\244\264\350\256\260\345\242\236\345\210\240\350\257\225\350\257\204\346\234\254\357\274\210\347\231\270\351\205\211\346\234\254\357\274\211.pdf" differ diff --git "a/test/test_data/books/\350\257\227\347\273\217\350\257\221\346\263\250.pdf" "b/test/test_data/books/\350\257\227\347\273\217\350\257\221\346\263\250.pdf" new file mode 100644 index 0000000..315aed9 Binary files /dev/null and "b/test/test_data/books/\350\257\227\347\273\217\350\257\221\346\263\250.pdf" differ diff --git a/web/templates/book.html b/web/templates/book.html index 629d06e..c0c46ba 100644 --- a/web/templates/book.html +++ b/web/templates/book.html @@ -42,10 +42,26 @@ + + {{ end }} {{ with $.stats }}