hako/internal/archival/store/archive_file_store.go

303 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"git.nakama.town/fmartingr/hako/internal/model"
"github.com/huandu/go-sqlbuilder"
)
// ArchiveFileStore handles database operations for archive files
type ArchiveFileStore struct {
readDB *sql.DB
writeDB *sql.DB
}
// NewArchiveFileStore creates a new ArchiveFileStore
func NewArchiveFileStore(readDB, writeDB *sql.DB) *ArchiveFileStore {
return &ArchiveFileStore{
readDB: readDB,
writeDB: writeDB,
}
}
// Create creates a new archive file
func (s *ArchiveFileStore) Create(ctx context.Context, file *model.ArchiveFile) error {
ib := sqlbuilder.NewInsertBuilder()
ib.InsertInto("archive_files")
ib.Cols("id", "archive_id", "extractor_key", "filename", "mime_type", "file_size", "storage_path", "hash_sha256", "content", "content_mime_type", "thumbnail_path", "created_at")
ib.Values(file.ID, file.ArchiveID, file.ArchiverKey, file.Filename, file.MimeType, file.FileSize, file.StoragePath, file.HashSha256, file.Content, file.ContentMimeType, file.ThumbnailPath, file.CreatedAt.Format("2006-01-02 15:04:05"))
query, args := ib.Build()
_, err := s.writeDB.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
return nil
}
// GetByID retrieves an archive file by ID
func (s *ArchiveFileStore) GetByID(ctx context.Context, id string) (*model.ArchiveFile, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("id", "archive_id", "extractor_key", "filename", "mime_type", "file_size", "storage_path", "hash_sha256", "content", "content_mime_type", "thumbnail_path", "created_at")
sb.From("archive_files")
sb.Where(sb.Equal("id", id))
query, args := sb.Build()
row := s.readDB.QueryRowContext(ctx, query, args...)
var file model.ArchiveFile
var createdAt string
err := row.Scan(&file.ID, &file.ArchiveID, &file.ArchiverKey, &file.Filename, &file.MimeType, &file.FileSize, &file.StoragePath, &file.HashSha256, &file.Content, &file.ContentMimeType, &file.ThumbnailPath, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("archive file not found")
}
return nil, fmt.Errorf("failed to get archive file: %w", err)
}
// Parse timestamp
file.CreatedAt, _ = parseTimestamp(createdAt)
return &file, nil
}
// ListByArchiveID retrieves all files for an archive
func (s *ArchiveFileStore) ListByArchiveID(ctx context.Context, opts ArchiveFileListOptions) ([]*model.ArchiveFile, error) {
opts.Defaults()
if err := opts.IsValid(); err != nil {
return nil, fmt.Errorf("invalid options: %w", err)
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("id", "archive_id", "extractor_key", "filename", "mime_type", "file_size", "storage_path", "hash_sha256", "content", "content_mime_type", "thumbnail_path", "created_at")
sb.From("archive_files")
sb.Where(sb.Equal("archive_id", opts.ArchiveID))
sb.OrderBy("created_at ASC")
query, args := sb.Build()
rows, err := s.readDB.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list archive files: %w", err)
}
defer func() { _ = rows.Close() }()
var files []*model.ArchiveFile
for rows.Next() {
var file model.ArchiveFile
var createdAt string
if err := rows.Scan(&file.ID, &file.ArchiveID, &file.ArchiverKey, &file.Filename, &file.MimeType, &file.FileSize, &file.StoragePath, &file.HashSha256, &file.Content, &file.ContentMimeType, &file.ThumbnailPath, &createdAt); err != nil {
return nil, fmt.Errorf("failed to scan archive file: %w", err)
}
// Parse timestamp
file.CreatedAt, _ = parseTimestamp(createdAt)
files = append(files, &file)
}
return files, nil
}
// GetTotalSizeByLinkID calculates the total size of all archive files for a link
func (s *ArchiveFileStore) GetTotalSizeByLinkID(ctx context.Context, linkID string) (int64, error) {
// Join archive_files with archives to get all files for a link
sb := sqlbuilder.NewSelectBuilder()
sb.Select("COALESCE(SUM(af.file_size), 0)")
sb.From("archive_files af")
sb.JoinWithOption(sqlbuilder.InnerJoin, "archives a", "af.archive_id = a.id")
sb.Where(sb.Equal("a.link_id", linkID))
query, args := sb.Build()
row := s.readDB.QueryRowContext(ctx, query, args...)
var totalSize int64
err := row.Scan(&totalSize)
if err != nil {
return 0, fmt.Errorf("failed to calculate total size: %w", err)
}
return totalSize, nil
}
// DeleteByArchiveID deletes all archive files for an archive
func (s *ArchiveFileStore) DeleteByArchiveID(ctx context.Context, archiveID string) error {
db := sqlbuilder.NewDeleteBuilder()
db.DeleteFrom("archive_files")
db.Where(db.Equal("archive_id", archiveID))
query, args := db.Build()
_, err := s.writeDB.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to delete archive files by archive ID: %w", err)
}
return nil
}
// ListByLinkID retrieves all archive files for a link (across all archives)
func (s *ArchiveFileStore) ListByLinkID(ctx context.Context, linkID string) ([]*model.ArchiveFile, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("af.id", "af.archive_id", "af.extractor_key", "af.filename", "af.mime_type", "af.file_size", "af.storage_path", "af.hash_sha256", "af.content", "af.content_mime_type", "af.thumbnail_path", "af.created_at")
sb.From("archive_files af")
sb.JoinWithOption(sqlbuilder.InnerJoin, "archives a", "af.archive_id = a.id")
sb.Where(sb.Equal("a.link_id", linkID))
sb.OrderBy("af.created_at ASC")
query, args := sb.Build()
rows, err := s.readDB.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list archive files by link ID: %w", err)
}
defer func() { _ = rows.Close() }()
var files []*model.ArchiveFile
for rows.Next() {
var file model.ArchiveFile
var createdAt string
if err := rows.Scan(&file.ID, &file.ArchiveID, &file.ArchiverKey, &file.Filename, &file.MimeType, &file.FileSize, &file.StoragePath, &file.HashSha256, &file.Content, &file.ContentMimeType, &file.ThumbnailPath, &createdAt); err != nil {
return nil, fmt.Errorf("failed to scan archive file: %w", err)
}
// Parse timestamp
file.CreatedAt, _ = parseTimestamp(createdAt)
files = append(files, &file)
}
return files, nil
}
// UpdateContent updates the content and content_mime_type fields of an archive file
func (s *ArchiveFileStore) UpdateContent(ctx context.Context, fileID string, content string, contentMimeType string) error {
ub := sqlbuilder.NewUpdateBuilder()
ub.Update("archive_files")
// Build the assignments array to ensure both fields are set
assignments := []string{
ub.Assign("content", content),
ub.Assign("content_mime_type", contentMimeType),
}
ub.Set(assignments...)
ub.Where(ub.Equal("id", fileID))
query, args := ub.Build()
result, err := s.writeDB.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to update archive file content: %w", err)
}
// Verify that a row was actually updated
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no rows updated for file ID: %s", fileID)
}
return nil
}
// SearchContent performs a full-text search on archive file content using FTS5
// Returns a list of archive file IDs that match the search query, filtered by userID
func (s *ArchiveFileStore) SearchContent(ctx context.Context, query string, userID string) ([]string, error) {
// Use raw SQL for FTS5 MATCH query since sqlbuilder doesn't support FTS5 directly
// The query parameter needs to be properly formatted for FTS5
// FTS5 uses a special syntax: "term1 term2" for AND, "term1 OR term2" for OR
sqlQuery := `
SELECT DISTINCT af.id
FROM archive_files_fts fts
INNER JOIN archive_files af ON fts.id = af.id
INNER JOIN archives a ON af.archive_id = a.id
WHERE fts.content MATCH ? AND a.user_id = ?
ORDER BY af.created_at DESC
`
rows, err := s.readDB.QueryContext(ctx, sqlQuery, query, userID)
if err != nil {
return nil, fmt.Errorf("failed to search archive file content: %w", err)
}
defer func() { _ = rows.Close() }()
var fileIDs []string
for rows.Next() {
var fileID string
if err := rows.Scan(&fileID); err != nil {
return nil, fmt.Errorf("failed to scan search result: %w", err)
}
fileIDs = append(fileIDs, fileID)
}
return fileIDs, nil
}
// GetThumbnailByArchiveID retrieves the thumbnail file for an archive
// Returns the first archive file with MIME type matching image/*+thumbnail
func (s *ArchiveFileStore) GetThumbnailByArchiveID(ctx context.Context, archiveID string) (*model.ArchiveFile, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("id", "archive_id", "extractor_key", "filename", "mime_type", "file_size", "storage_path", "hash_sha256", "content", "content_mime_type", "thumbnail_path", "created_at")
sb.From("archive_files")
sb.Where(sb.Equal("archive_id", archiveID))
sb.Where(sb.Like("mime_type", "image/%+thumbnail"))
sb.OrderBy("created_at ASC")
sb.Limit(1)
query, args := sb.Build()
row := s.readDB.QueryRowContext(ctx, query, args...)
var file model.ArchiveFile
var createdAt string
err := row.Scan(&file.ID, &file.ArchiveID, &file.ArchiverKey, &file.Filename, &file.MimeType, &file.FileSize, &file.StoragePath, &file.HashSha256, &file.Content, &file.ContentMimeType, &file.ThumbnailPath, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No thumbnail found is not an error
}
return nil, fmt.Errorf("failed to get thumbnail: %w", err)
}
// Parse timestamp
file.CreatedAt, _ = parseTimestamp(createdAt)
return &file, nil
}
// GetThumbnailByLinkID retrieves the thumbnail file for a link's latest archive
// Returns the first archive file with MIME type matching image/*+thumbnail from the latest archive
func (s *ArchiveFileStore) GetThumbnailByLinkID(ctx context.Context, linkID string) (*model.ArchiveFile, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("af.id", "af.archive_id", "af.extractor_key", "af.filename", "af.mime_type", "af.file_size", "af.storage_path", "af.hash_sha256", "af.content", "af.content_mime_type", "af.thumbnail_path", "af.created_at")
sb.From("archive_files af")
sb.JoinWithOption(sqlbuilder.InnerJoin, "archives a", "af.archive_id = a.id")
sb.Where(sb.Equal("a.link_id", linkID))
sb.Where(sb.Like("af.mime_type", "image/%+thumbnail"))
sb.OrderBy("a.created_at DESC", "af.created_at ASC")
sb.Limit(1)
query, args := sb.Build()
row := s.readDB.QueryRowContext(ctx, query, args...)
var file model.ArchiveFile
var createdAt string
err := row.Scan(&file.ID, &file.ArchiveID, &file.ArchiverKey, &file.Filename, &file.MimeType, &file.FileSize, &file.StoragePath, &file.HashSha256, &file.Content, &file.ContentMimeType, &file.ThumbnailPath, &createdAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No thumbnail found is not an error
}
return nil, fmt.Errorf("failed to get thumbnail: %w", err)
}
// Parse timestamp
file.CreatedAt, _ = parseTimestamp(createdAt)
return &file, nil
}