303 lines
11 KiB
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
|
|
}
|