display files with cache image
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/vaultdrop/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
uploadDir := cfg.UploadDir
|
||||||
|
thumbnailDir := cfg.ThumbnailDir
|
||||||
|
|
||||||
|
fmt.Println("=== VaultDrop Orphan GC ===")
|
||||||
|
fmt.Printf("Upload dir: %s\n", uploadDir)
|
||||||
|
fmt.Printf("Thumbnail dir: %s\n", thumbnailDir)
|
||||||
|
|
||||||
|
var fileCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM files WHERE is_folder = false AND storage_key != ''").Scan(&fileCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to count files: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Files in DB: %d\n", fileCount)
|
||||||
|
|
||||||
|
rows, err := db.Query("SELECT id, storage_key FROM files WHERE is_folder = false AND storage_key != ''")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to query files: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
dbPaths := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, storageKey string
|
||||||
|
if err := rows.Scan(&id, &storageKey); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbPaths[storageKey] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to read upload dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orphanFiles := 0
|
||||||
|
freedBytes := int64(0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(uploadDir, entry.Name())
|
||||||
|
relPath := "./" + fullPath
|
||||||
|
|
||||||
|
if _, ok := dbPaths[fullPath]; !ok {
|
||||||
|
if _, ok2 := dbPaths[relPath]; !ok2 {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err == nil {
|
||||||
|
freedBytes += info.Size()
|
||||||
|
}
|
||||||
|
orphanFiles++
|
||||||
|
fmt.Printf(" ORPHAN FILE: %s\n", fullPath)
|
||||||
|
os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM thumbnails").Scan(&thumbCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to count thumbnails: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Thumbnails in DB: %d\n", thumbCount)
|
||||||
|
|
||||||
|
thumbRows, err := db.Query("SELECT id, file_id, storage_key FROM thumbnails")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to query thumbnails: %v", err)
|
||||||
|
}
|
||||||
|
defer thumbRows.Close()
|
||||||
|
|
||||||
|
dbThumbPaths := make(map[string]string)
|
||||||
|
for thumbRows.Next() {
|
||||||
|
var id, fileID, storageKey string
|
||||||
|
if err := thumbRows.Scan(&id, &fileID, &storageKey); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbThumbPaths[storageKey] = fileID
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(thumbnailDir); err == nil {
|
||||||
|
fileDirs, err := os.ReadDir(thumbnailDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, fileDir := range fileDirs {
|
||||||
|
if !fileDir.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileDirPath := filepath.Join(thumbnailDir, fileDir.Name())
|
||||||
|
thumbFiles, err := os.ReadDir(fileDirPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, tf := range thumbFiles {
|
||||||
|
if tf.IsDir() || strings.HasPrefix(tf.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
thumbPath := filepath.Join(fileDirPath, tf.Name())
|
||||||
|
relThumbPath := "./" + thumbPath
|
||||||
|
if _, ok := dbThumbPaths[thumbPath]; !ok {
|
||||||
|
if _, ok2 := dbThumbPaths[relThumbPath]; !ok2 {
|
||||||
|
info, err := tf.Info()
|
||||||
|
if err == nil {
|
||||||
|
freedBytes += info.Size()
|
||||||
|
}
|
||||||
|
orphanFiles++
|
||||||
|
fmt.Printf(" ORPHAN THUMB: %s\n", thumbPath)
|
||||||
|
os.Remove(thumbPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, _ := os.ReadDir(fileDirPath)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
os.Remove(fileDirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== Summary ===\n")
|
||||||
|
fmt.Printf("Orphan files removed: %d\n", orphanFiles)
|
||||||
|
fmt.Printf("Space freed: %.2f MB\n", float64(freedBytes)/(1024*1024))
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ func main() {
|
|||||||
fileSvc := service.NewFileService(queries, cfg)
|
fileSvc := service.NewFileService(queries, cfg)
|
||||||
ocrSvc := service.NewOCRService(cfg, fileSvc)
|
ocrSvc := service.NewOCRService(cfg, fileSvc)
|
||||||
conversionSvc := service.NewConversionService(queries, cfg)
|
conversionSvc := service.NewConversionService(queries, cfg)
|
||||||
urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost)
|
urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost, cfg.URLExpiryMinutes)
|
||||||
authSvc, err := auth.NewAuthService(queries, cfg)
|
authSvc, err := auth.NewAuthService(queries, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create auth service: %v", err)
|
log.Fatalf("Failed to create auth service: %v", err)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
@@ -13,6 +16,7 @@ type Config struct {
|
|||||||
LibreOfficePath string
|
LibreOfficePath string
|
||||||
PdftoppmPath string
|
PdftoppmPath string
|
||||||
ThumbnailDir string
|
ThumbnailDir string
|
||||||
|
URLExpiryMinutes int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -27,9 +31,20 @@ func Load() *Config {
|
|||||||
LibreOfficePath: envOr("LIBREOFFICE_PATH", "/usr/bin/libreoffice"),
|
LibreOfficePath: envOr("LIBREOFFICE_PATH", "/usr/bin/libreoffice"),
|
||||||
PdftoppmPath: envOr("PDFTOPPM_PATH", "/usr/bin/pdftoppm"),
|
PdftoppmPath: envOr("PDFTOPPM_PATH", "/usr/bin/pdftoppm"),
|
||||||
ThumbnailDir: envOr("THUMBNAIL_DIR", "./uploads/thumbnails"),
|
ThumbnailDir: envOr("THUMBNAIL_DIR", "./uploads/thumbnails"),
|
||||||
|
URLExpiryMinutes: envOrInt("URL_EXPIRY_MINUTES", 60),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envOrInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func envOr(key, fallback string) string {
|
func envOr(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,81 @@ func (q *Queries) DeleteFile(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findDuplicateByChecksum = `-- name: FindDuplicateByChecksum :one
|
||||||
|
SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files
|
||||||
|
WHERE checksum = $1 AND is_folder = false
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) FindDuplicateByChecksum(ctx context.Context, checksum string) (File, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, findDuplicateByChecksum, checksum)
|
||||||
|
var i File
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.Size,
|
||||||
|
&i.StorageKey,
|
||||||
|
&i.Checksum,
|
||||||
|
&i.OcrText,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.ParentFileID,
|
||||||
|
&i.IsFolder,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const findDuplicatesByNameSize = `-- name: FindDuplicatesByNameSize :many
|
||||||
|
SELECT id, name, mime_type, size, checksum, created_at FROM files
|
||||||
|
WHERE name = $1 AND size = $2 AND is_folder = false
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type FindDuplicatesByNameSizeParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindDuplicatesByNameSizeRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) FindDuplicatesByNameSize(ctx context.Context, arg FindDuplicatesByNameSizeParams) ([]FindDuplicatesByNameSizeRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, findDuplicatesByNameSize, arg.Name, arg.Size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []FindDuplicatesByNameSizeRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i FindDuplicatesByNameSizeRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.Size,
|
||||||
|
&i.Checksum,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getFile = `-- name: GetFile :one
|
const getFile = `-- name: GetFile :one
|
||||||
SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files
|
SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files
|
||||||
WHERE id = $1 LIMIT 1
|
WHERE id = $1 LIMIT 1
|
||||||
|
|||||||
@@ -45,3 +45,13 @@ WHERE id = $4;
|
|||||||
-- name: DeleteFile :exec
|
-- name: DeleteFile :exec
|
||||||
DELETE FROM files
|
DELETE FROM files
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: FindDuplicatesByNameSize :many
|
||||||
|
SELECT id, name, mime_type, size, checksum, created_at FROM files
|
||||||
|
WHERE name = $1 AND size = $2 AND is_folder = false
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: FindDuplicateByChecksum :one
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE checksum = $1 AND is_folder = false
|
||||||
|
LIMIT 1;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -218,10 +219,22 @@ func (h *FileHandler) Get(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *FileHandler) Delete(c *gin.Context) {
|
func (h *FileHandler) Delete(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
storagePath, _ := h.files.GetStoragePath(id)
|
||||||
|
thumbnails, _ := h.files.GetThumbnailsByFileID(id)
|
||||||
|
|
||||||
if err := h.files.Delete(id); err != nil {
|
if err := h.files.Delete(id); err != nil {
|
||||||
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete file")
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if storagePath != "" {
|
||||||
|
os.Remove(path.Clean(storagePath))
|
||||||
|
}
|
||||||
|
for _, t := range thumbnails {
|
||||||
|
os.Remove(path.Clean(t.StorageKey))
|
||||||
|
}
|
||||||
|
|
||||||
api.Success(c, gin.H{"deleted": true})
|
api.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,3 +445,47 @@ func (h *FileHandler) ServeThumbnail(c *gin.Context) {
|
|||||||
|
|
||||||
c.File(path.Clean(storagePath))
|
c.File(path.Clean(storagePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FileHandler) CheckDuplicates(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Size int64 `json:"size" binding:"required"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain 'name' and 'size'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicates, err := h.files.FindDuplicatesByNameSize(body.Name, body.Size)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to check duplicates")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]dupResponse, len(duplicates))
|
||||||
|
for i, d := range duplicates {
|
||||||
|
resp[i] = dupResponse{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
MimeType: d.MimeType,
|
||||||
|
Size: d.Size,
|
||||||
|
Checksum: d.Checksum,
|
||||||
|
CreatedAt: d.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(c, gin.H{
|
||||||
|
"duplicates": resp,
|
||||||
|
"count": len(resp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ func SetupRoutes(r *gin.Engine, h *Handler, authMiddleware *auth.AuthService) {
|
|||||||
protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
|
protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
|
||||||
|
|
||||||
protected.GET("/files/:id/thumbnails", h.File.GetThumbnails)
|
protected.GET("/files/:id/thumbnails", h.File.GetThumbnails)
|
||||||
|
|
||||||
|
protected.POST("/files/dedup-check", h.File.CheckDuplicates)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ func (s *FileService) Upload(file *multipart.FileHeader) (*model.UploadResult, e
|
|||||||
|
|
||||||
checksum := hex.EncodeToString(CreateSHA256Hash(data))
|
checksum := hex.EncodeToString(CreateSHA256Hash(data))
|
||||||
|
|
||||||
|
existing, err := s.queries.FindDuplicateByChecksum(context.Background(), checksum)
|
||||||
|
if err == nil && existing.ID != "" {
|
||||||
|
os.Remove(dst)
|
||||||
|
return &model.UploadResult{
|
||||||
|
ID: existing.ID,
|
||||||
|
Name: existing.Name,
|
||||||
|
Path: existing.StorageKey,
|
||||||
|
MimeType: existing.MimeType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
dbFile, err := s.queries.CreateFile(context.Background(), db.CreateFileParams{
|
dbFile, err := s.queries.CreateFile(context.Background(), db.CreateFileParams{
|
||||||
Name: file.Filename,
|
Name: file.Filename,
|
||||||
MimeType: file.Header.Get("Content-Type"),
|
MimeType: file.Header.Get("Content-Type"),
|
||||||
@@ -280,6 +291,13 @@ func (s *FileService) GetBestThumbnail(fileID, preferredLabel string) *model.Thu
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FileService) FindDuplicatesByNameSize(name string, size int64) ([]db.FindDuplicatesByNameSizeRow, error) {
|
||||||
|
return s.queries.FindDuplicatesByNameSize(context.Background(), db.FindDuplicatesByNameSizeParams{
|
||||||
|
Name: name,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func dbToModel(f db.File, dbTags []db.Tag) model.File {
|
func dbToModel(f db.File, dbTags []db.Tag) model.File {
|
||||||
tags := make([]model.Tag, len(dbTags))
|
tags := make([]model.Tag, len(dbTags))
|
||||||
for i, t := range dbTags {
|
for i, t := range dbTags {
|
||||||
|
|||||||
@@ -11,10 +11,18 @@ import (
|
|||||||
type URLService struct {
|
type URLService struct {
|
||||||
secret string
|
secret string
|
||||||
serverHost string
|
serverHost string
|
||||||
|
expiryDuration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewURLService(secret, serverHost string) *URLService {
|
func NewURLService(secret, serverHost string, expiryMinutes int) *URLService {
|
||||||
return &URLService{secret: secret, serverHost: serverHost}
|
if expiryMinutes <= 0 {
|
||||||
|
expiryMinutes = 60
|
||||||
|
}
|
||||||
|
return &URLService{
|
||||||
|
secret: secret,
|
||||||
|
serverHost: serverHost,
|
||||||
|
expiryDuration: time.Duration(expiryMinutes) * time.Minute,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *URLService) sign(fileID string, expires int64) string {
|
func (s *URLService) sign(fileID string, expires int64) string {
|
||||||
@@ -25,7 +33,7 @@ func (s *URLService) sign(fileID string, expires int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *URLService) GenerateDownloadURL(fileUUID string) string {
|
func (s *URLService) GenerateDownloadURL(fileUUID string) string {
|
||||||
expires := time.Now().Add(10 * time.Minute).Unix()
|
expires := time.Now().Add(s.expiryDuration).Unix()
|
||||||
sig := s.sign(fileUUID, expires)
|
sig := s.sign(fileUUID, expires)
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
@@ -38,7 +46,7 @@ func (s *URLService) GenerateDownloadURL(fileUUID string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *URLService) GenerateThumbnailURL(thumbUUID string) string {
|
func (s *URLService) GenerateThumbnailURL(thumbUUID string) string {
|
||||||
expires := time.Now().Add(10 * time.Minute).Unix()
|
expires := time.Now().Add(s.expiryDuration).Unix()
|
||||||
sig := s.sign(thumbUUID, expires)
|
sig := s.sign(thumbUUID, expires)
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
|
|||||||
+2
-1
@@ -44,7 +44,8 @@
|
|||||||
"photosPermission": "VaultDrop a besoin d'accéder à vos photos pour les afficher et les organiser.",
|
"photosPermission": "VaultDrop a besoin d'accéder à vos photos pour les afficher et les organiser.",
|
||||||
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
|
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-image"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-17
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFileImage } from '../hooks/useFiles';
|
import { useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles';
|
||||||
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles';
|
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles';
|
||||||
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
||||||
import { FileItem, isFolder } from '../types';
|
import { FileItem, isFolder } from '../types';
|
||||||
@@ -84,9 +84,7 @@ function formatDateLabel(key: string): string {
|
|||||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
||||||
const { data, isLoading } = useFileImage(file.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.gridItem, selected && styles.gridItemSelected]}
|
style={[styles.gridItem, selected && styles.gridItemSelected]}
|
||||||
@@ -96,12 +94,11 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
|
|||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<FileThumbnail
|
<FileThumbnail
|
||||||
uri={data?.data?.url ?? file.localUri}
|
uri={file.url ?? file.localUri}
|
||||||
thumbnailUrl={file.thumbnailUrl}
|
thumbnailUrl={file.thumbnailUrl}
|
||||||
mimeType={file.mimeType}
|
mimeType={file.mimeType}
|
||||||
fileName={file.name}
|
fileName={file.name}
|
||||||
size={ITEM_SIZE}
|
size={ITEM_SIZE}
|
||||||
isLoading={isLoading}
|
|
||||||
syncStatus={file.syncStatus}
|
syncStatus={file.syncStatus}
|
||||||
/>
|
/>
|
||||||
{selected && (
|
{selected && (
|
||||||
@@ -114,7 +111,28 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
|
|||||||
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
|
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const FileGroup = React.memo(function FileGroup({ groupFiles, selectedIds, onItemPress, onItemLongPress }: {
|
||||||
|
groupFiles: UnifiedFileItem[];
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
onItemPress: (file: UnifiedFileItem) => void;
|
||||||
|
onItemLongPress: (file: UnifiedFileItem) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{groupFiles.map((file) => (
|
||||||
|
<FileGridItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
selected={selectedIds.has(file.id)}
|
||||||
|
onPress={() => onItemPress(file)}
|
||||||
|
onLongPress={() => onItemLongPress(file)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
@@ -447,17 +465,12 @@ export function HomeScreen() {
|
|||||||
<Text style={styles.sectionTitle}>{label}</Text>
|
<Text style={styles.sectionTitle}>{label}</Text>
|
||||||
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
|
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.grid}>
|
<FileGroup
|
||||||
{groupFiles.map((file) => (
|
groupFiles={groupFiles}
|
||||||
<FileGridItem
|
selectedIds={selectedIds}
|
||||||
key={file.id}
|
onItemPress={handleItemPress}
|
||||||
file={file}
|
onItemLongPress={handleItemLongPress}
|
||||||
selected={selectedIds.has(file.id)}
|
|
||||||
onPress={() => handleItemPress(file)}
|
|
||||||
onLongPress={() => handleItemLongPress(file)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
|
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { SyncStatusBadge } from './SyncStatusBadge';
|
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||||
@@ -40,7 +41,7 @@ interface FileThumbnailProps {
|
|||||||
syncStatus?: SyncStatus;
|
syncStatus?: SyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) {
|
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) {
|
||||||
const info = getFileInfo(mimeType, fileName);
|
const info = getFileInfo(mimeType, fileName);
|
||||||
const ext = getExtension(fileName);
|
const ext = getExtension(fileName);
|
||||||
|
|
||||||
@@ -57,7 +58,13 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
|||||||
if (imageUri) {
|
if (imageUri) {
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size }}>
|
<View style={{ width: size, height: size }}>
|
||||||
<Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />
|
<Image
|
||||||
|
source={imageUri}
|
||||||
|
style={[styles.image, { width: size, height: size }]}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
/>
|
||||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -72,7 +79,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
|||||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ interface SyncStatusBadgeProps {
|
|||||||
|
|
||||||
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string }> = {
|
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string }> = {
|
||||||
local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' },
|
local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' },
|
||||||
|
syncing: { icon: 'sync', color: '#FF9800', bg: 'rgba(255,243,224,0.9)' },
|
||||||
synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' },
|
synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' },
|
||||||
cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' },
|
cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' },
|
||||||
|
conflict: { icon: 'warning', color: '#E53935', bg: 'rgba(255,235,238,0.9)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) {
|
export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) {
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ export function useAutoSync() {
|
|||||||
|
|
||||||
if (pendingFiles.length === 0) return;
|
if (pendingFiles.length === 0) return;
|
||||||
|
|
||||||
console.log(`[useAutoSync] mode=${globalMode}, upload de ${pendingFiles.length} fichier(s)`);
|
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
|
||||||
for (const entry of pendingFiles) {
|
for (const entry of pendingFiles) {
|
||||||
@@ -107,15 +106,12 @@ export function useAutoSync() {
|
|||||||
backendFileId: uploaded.id,
|
backendFileId: uploaded.id,
|
||||||
syncStatus: 'synced',
|
syncStatus: 'synced',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[useAutoSync] "${entry.name}" uploadé → id=${uploaded.id}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[useAutoSync] échec upload "${entry.name}":`, err);
|
// upload failed, will retry on next cycle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
console.log(`[useAutoSync] sync terminé`);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
isRunning.current = false;
|
isRunning.current = false;
|
||||||
@@ -123,9 +119,11 @@ export function useAutoSync() {
|
|||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(checkAndSync, 30_000);
|
const timeout = setTimeout(() => {
|
||||||
checkAndSync();
|
checkAndSync();
|
||||||
return () => clearInterval(interval);
|
}, 5_000);
|
||||||
|
const interval = setInterval(checkAndSync, 30_000);
|
||||||
|
return () => { clearTimeout(timeout); clearInterval(interval); };
|
||||||
}, [checkAndSync]);
|
}, [checkAndSync]);
|
||||||
|
|
||||||
return { triggerSync: checkAndSync };
|
return { triggerSync: checkAndSync };
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
|||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +120,6 @@ export function useDeviceFiles() {
|
|||||||
|
|
||||||
setFiles(deviceFiles);
|
setFiles(deviceFiles);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useDeviceFiles] scan error:', err);
|
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -132,11 +130,8 @@ export function useDeviceFiles() {
|
|||||||
const visibleFolders = safDirectory.getVisibleFolders();
|
const visibleFolders = safDirectory.getVisibleFolders();
|
||||||
if (visibleFolders.length === 0) return;
|
if (visibleFolders.length === 0) return;
|
||||||
|
|
||||||
const safFiles: DeviceFile[] = [];
|
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
|
||||||
for (const folder of visibleFolders) {
|
const safFiles = results.flat();
|
||||||
const folderFiles = await scanSafFolder(folder);
|
|
||||||
safFiles.push(...folderFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
|
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
|
||||||
@@ -191,7 +186,6 @@ export function useDeviceFiles() {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useDeviceFiles] pickDirectory error:', err);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { ENDPOINTS } from '../constants/api';
|
import { ENDPOINTS } from '../constants/api';
|
||||||
import { FileItem, PaginatedResponse } from '../types';
|
import { FileItem, PaginatedResponse } from '../types';
|
||||||
|
import { metadataCache } from '../services/metadataCache';
|
||||||
|
|
||||||
export function useFiles(page: number = 1, limit: number = 20) {
|
export function useFiles(page: number = 1, limit: number = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['files', page, limit],
|
queryKey: ['files', page, limit],
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
apiClient.get<PaginatedResponse<FileItem>>(
|
const res = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||||
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`
|
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`
|
||||||
),
|
);
|
||||||
|
metadataCache.setFiles(res.data, res.meta.page, res.meta.total);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
initialData: () => {
|
||||||
|
const cached = metadataCache.getFiles();
|
||||||
|
if (cached && cached.page === page) {
|
||||||
|
return { data: cached.files, meta: { page: cached.page, total: cached.total } };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +48,7 @@ export function useDeleteFile() {
|
|||||||
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
metadataCache.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -48,6 +61,7 @@ export function useAddTags() {
|
|||||||
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
|
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
metadataCache.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,6 +74,7 @@ export function useMoveFiles() {
|
|||||||
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
|
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
metadataCache.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { LocalFileEntry } from '../types';
|
|||||||
|
|
||||||
export function useLocalFiles() {
|
export function useLocalFiles() {
|
||||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
||||||
|
const lastDeviceCount = useRef(0);
|
||||||
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (deviceFiles.length === 0) return;
|
||||||
|
if (deviceFiles.length === lastDeviceCount.current) return;
|
||||||
|
lastDeviceCount.current = deviceFiles.length;
|
||||||
|
|
||||||
|
const newEntries: LocalFileEntry[] = [];
|
||||||
for (const df of deviceFiles) {
|
for (const df of deviceFiles) {
|
||||||
if (localFileRegistry.get(df.id)) continue;
|
if (localFileRegistry.get(df.id)) continue;
|
||||||
const entry: LocalFileEntry = {
|
newEntries.push({
|
||||||
id: df.id,
|
id: df.id,
|
||||||
localUri: df.uri,
|
localUri: df.uri,
|
||||||
name: df.name,
|
name: df.name,
|
||||||
@@ -20,12 +24,15 @@ export function useLocalFiles() {
|
|||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
createdAt: df.createdAt,
|
createdAt: df.createdAt,
|
||||||
folderId: df.folderId,
|
folderId: df.folderId,
|
||||||
};
|
});
|
||||||
localFileRegistry.register(entry);
|
}
|
||||||
|
if (newEntries.length > 0) {
|
||||||
|
localFileRegistry.registerBatch(newEntries);
|
||||||
}
|
}
|
||||||
}, [deviceFiles]);
|
}, [deviceFiles]);
|
||||||
|
|
||||||
const localFiles = useMemo(() => {
|
const localFiles = useMemo(() => {
|
||||||
|
const registryEntries = localFileRegistry.getAll();
|
||||||
const merged = new Map<string, LocalFileEntry>();
|
const merged = new Map<string, LocalFileEntry>();
|
||||||
|
|
||||||
for (const entry of registryEntries) {
|
for (const entry of registryEntries) {
|
||||||
@@ -47,10 +54,8 @@ export function useLocalFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Array.from(merged.values());
|
return Array.from(merged.values());
|
||||||
console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`);
|
}, [deviceFiles]);
|
||||||
return result;
|
|
||||||
}, [deviceFiles, registryEntries]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localFiles,
|
localFiles,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
|
||||||
|
import { useFiles } from './useFiles';
|
||||||
|
import { localFileRegistry } from '../services/localFileRegistry';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
import { LocalFileEntry } from '../types';
|
||||||
|
import { setIsSyncing } from './useSyncQueue';
|
||||||
|
|
||||||
|
const SYNC_DIR = `${documentDirectory}synced-files/`;
|
||||||
|
|
||||||
|
export function usePullSync() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isRunning = useRef(false);
|
||||||
|
|
||||||
|
const pullNewFiles = useCallback(async () => {
|
||||||
|
if (isRunning.current) return { pulled: 0 };
|
||||||
|
isRunning.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSyncing(true);
|
||||||
|
|
||||||
|
const res = await apiClient.get<{ data: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
url?: string;
|
||||||
|
}> }>('/files?page=1&limit=100&thumbnail=thumbnail');
|
||||||
|
|
||||||
|
const backendFiles = res.data ?? [];
|
||||||
|
const registry = localFileRegistry.getAll();
|
||||||
|
const existingBackendIds = new Set(
|
||||||
|
registry.filter((e) => e.backendFileId).map((e) => e.backendFileId)
|
||||||
|
);
|
||||||
|
|
||||||
|
let pulled = 0;
|
||||||
|
|
||||||
|
for (const bf of backendFiles) {
|
||||||
|
if (existingBackendIds.has(bf.id)) continue;
|
||||||
|
if (bf.size === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await apiClient.get<{ data: { url: string } }>(`/files/${bf.id}`);
|
||||||
|
const downloadUrl = detail.data.url;
|
||||||
|
|
||||||
|
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
|
||||||
|
const safeName = bf.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const fileUri = `${SYNC_DIR}${bf.id}_${safeName}`;
|
||||||
|
|
||||||
|
const result = await downloadAsync(downloadUrl, fileUri);
|
||||||
|
|
||||||
|
const entry: LocalFileEntry = {
|
||||||
|
id: `pull_${bf.id}`,
|
||||||
|
backendFileId: bf.id,
|
||||||
|
localUri: result.uri,
|
||||||
|
name: bf.name,
|
||||||
|
mimeType: bf.mimeType,
|
||||||
|
size: bf.size,
|
||||||
|
syncStatus: 'synced',
|
||||||
|
createdAt: bf.createdAt,
|
||||||
|
};
|
||||||
|
localFileRegistry.register(entry);
|
||||||
|
pulled++;
|
||||||
|
} catch {
|
||||||
|
// skip individual file failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pulled > 0) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pulled };
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
isRunning.current = false;
|
||||||
|
}
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return { pullNewFiles };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } fro
|
|||||||
import { useFiles } from './useFiles';
|
import { useFiles } from './useFiles';
|
||||||
import { useLocalFiles } from './useLocalFiles';
|
import { useLocalFiles } from './useLocalFiles';
|
||||||
import { localFileRegistry } from '../services/localFileRegistry';
|
import { localFileRegistry } from '../services/localFileRegistry';
|
||||||
|
import { thumbnailCache } from '../services/thumbnailCache';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
|
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export interface UnifiedFileItem {
|
|||||||
url?: string;
|
url?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
isDeviceFile?: boolean;
|
isDeviceFile?: boolean;
|
||||||
|
duplicateOf?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
||||||
@@ -41,6 +43,15 @@ function getExtension(name: string): string {
|
|||||||
return dot >= 0 ? name.slice(dot) : '';
|
return dot >= 0 ? name.slice(dot) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExpiresFromUrl(url: string): number {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const expires = u.searchParams.get('expires');
|
||||||
|
if (expires) return Number(expires) * 1000;
|
||||||
|
} catch {}
|
||||||
|
return Date.now() + 50 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||||
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
||||||
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
||||||
@@ -57,8 +68,25 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameSizeIndex = new Map<string, string>();
|
||||||
|
for (const bf of backendFiles) {
|
||||||
|
if (!bf.isFolder && bf.size > 0) {
|
||||||
|
nameSizeIndex.set(`${bf.name}::${bf.size}`, bf.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const bf of backendFiles) {
|
for (const bf of backendFiles) {
|
||||||
const localEntry = backendIdToLocal.get(bf.id);
|
const localEntry = backendIdToLocal.get(bf.id);
|
||||||
|
|
||||||
|
let thumbUrl = bf.thumbnailUrl;
|
||||||
|
if (thumbUrl && bf.thumbnailUrl) {
|
||||||
|
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||||
|
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||||
|
} else {
|
||||||
|
const cached = thumbnailCache.get(bf.id);
|
||||||
|
if (cached) thumbUrl = cached;
|
||||||
|
}
|
||||||
|
|
||||||
merged.set(bf.id, {
|
merged.set(bf.id, {
|
||||||
id: bf.id,
|
id: bf.id,
|
||||||
backendFileId: bf.id,
|
backendFileId: bf.id,
|
||||||
@@ -74,7 +102,7 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
|||||||
isFolder: bf.isFolder,
|
isFolder: bf.isFolder,
|
||||||
parentFileId: bf.parentFileId,
|
parentFileId: bf.parentFileId,
|
||||||
url: bf.url,
|
url: bf.url,
|
||||||
thumbnailUrl: bf.thumbnailUrl,
|
thumbnailUrl: thumbUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +110,18 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
|||||||
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
|
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
|
||||||
if (merged.has(lf.id)) continue;
|
if (merged.has(lf.id)) continue;
|
||||||
|
|
||||||
|
if (!lf.folderId && lf.size > 0) {
|
||||||
|
const key = `${lf.name}::${lf.size}`;
|
||||||
|
const matchId = nameSizeIndex.get(key);
|
||||||
|
if (matchId) {
|
||||||
|
const existing = merged.get(matchId);
|
||||||
|
if (existing && !existing.localUri && lf.localUri) {
|
||||||
|
merged.set(matchId, { ...existing, localUri: lf.localUri, syncStatus: 'synced' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
merged.set(lf.id, {
|
merged.set(lf.id, {
|
||||||
id: lf.id,
|
id: lf.id,
|
||||||
backendFileId: lf.backendFileId,
|
backendFileId: lf.backendFileId,
|
||||||
@@ -138,6 +178,16 @@ export function useUnifiedFilesByParent(parentId: string) {
|
|||||||
|
|
||||||
for (const bf of backendFiles) {
|
for (const bf of backendFiles) {
|
||||||
const localEntry = backendIdToLocal.get(bf.id);
|
const localEntry = backendIdToLocal.get(bf.id);
|
||||||
|
|
||||||
|
let thumbUrl = bf.thumbnailUrl;
|
||||||
|
if (thumbUrl && bf.thumbnailUrl) {
|
||||||
|
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||||
|
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||||
|
} else {
|
||||||
|
const cached = thumbnailCache.get(bf.id);
|
||||||
|
if (cached) thumbUrl = cached;
|
||||||
|
}
|
||||||
|
|
||||||
merged.set(bf.id, {
|
merged.set(bf.id, {
|
||||||
id: bf.id,
|
id: bf.id,
|
||||||
backendFileId: bf.id,
|
backendFileId: bf.id,
|
||||||
@@ -153,7 +203,7 @@ export function useUnifiedFilesByParent(parentId: string) {
|
|||||||
isFolder: bf.isFolder,
|
isFolder: bf.isFolder,
|
||||||
parentFileId: bf.parentFileId,
|
parentFileId: bf.parentFileId,
|
||||||
url: bf.url,
|
url: bf.url,
|
||||||
thumbnailUrl: bf.thumbnailUrl,
|
thumbnailUrl: thumbUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+21
@@ -16,6 +16,7 @@
|
|||||||
"expo": "~57.0.4",
|
"expo": "~57.0.4",
|
||||||
"expo-document-picker": "~57.0.0",
|
"expo-document-picker": "~57.0.0",
|
||||||
"expo-file-system": "~57.0.0",
|
"expo-file-system": "~57.0.0",
|
||||||
|
"expo-image": "~57.0.1",
|
||||||
"expo-image-picker": "~57.0.2",
|
"expo-image-picker": "~57.0.2",
|
||||||
"expo-media-library": "~57.0.3",
|
"expo-media-library": "~57.0.3",
|
||||||
"expo-print": "~57.0.0",
|
"expo-print": "~57.0.0",
|
||||||
@@ -3082,6 +3083,26 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image": {
|
||||||
|
"version": "57.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.1.tgz",
|
||||||
|
"integrity": "sha512-EP0lisd2bUqtErry4weRcMW9bLMxtKsht/MLLK3/3do5u4ZMiJbWkY5zfYV+WYmeGab7x9G0sjbeFeEagYGjMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sf-symbols-typescript": "^2.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-web": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-web": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-image-loader": {
|
"node_modules/expo-image-loader": {
|
||||||
"version": "57.0.0",
|
"version": "57.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"expo": "~57.0.4",
|
"expo": "~57.0.4",
|
||||||
"expo-document-picker": "~57.0.0",
|
"expo-document-picker": "~57.0.0",
|
||||||
"expo-file-system": "~57.0.0",
|
"expo-file-system": "~57.0.0",
|
||||||
|
"expo-image": "~57.0.1",
|
||||||
"expo-image-picker": "~57.0.2",
|
"expo-image-picker": "~57.0.2",
|
||||||
"expo-media-library": "~57.0.3",
|
"expo-media-library": "~57.0.3",
|
||||||
"expo-print": "~57.0.0",
|
"expo-print": "~57.0.0",
|
||||||
|
|||||||
@@ -3,58 +3,113 @@ import { LocalFileEntry, SyncStatus } from '../types';
|
|||||||
|
|
||||||
const storage = createMMKV({ id: 'vaultdrop-local-files' });
|
const storage = createMMKV({ id: 'vaultdrop-local-files' });
|
||||||
|
|
||||||
const INDEX_KEY = 'local_files_index';
|
const BLOB_KEY = 'local_files_v2';
|
||||||
|
const LEGACY_INDEX_KEY = 'local_files_index';
|
||||||
|
|
||||||
function getAllIds(): string[] {
|
interface RegistryBlob {
|
||||||
const raw = storage.getString(INDEX_KEY);
|
entries: Record<string, LocalFileEntry>;
|
||||||
if (!raw) return [];
|
backendIndex: Record<string, string>;
|
||||||
return JSON.parse(raw) as string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAllIds(ids: string[]) {
|
let memoryCache: RegistryBlob | null = null;
|
||||||
storage.set(INDEX_KEY, JSON.stringify(ids));
|
|
||||||
|
function loadBlob(): RegistryBlob {
|
||||||
|
if (memoryCache) return memoryCache;
|
||||||
|
|
||||||
|
const raw = storage.getString(BLOB_KEY);
|
||||||
|
if (raw) {
|
||||||
|
memoryCache = JSON.parse(raw) as RegistryBlob;
|
||||||
|
return memoryCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryKey(id: string): string {
|
memoryCache = migrateFromLegacy();
|
||||||
return `local_file_${id}`;
|
saveBlob(memoryCache);
|
||||||
|
return memoryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: RegistryBlob) {
|
||||||
|
memoryCache = blob;
|
||||||
|
storage.set(BLOB_KEY, JSON.stringify(blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateFromLegacy(): RegistryBlob {
|
||||||
|
const blob: RegistryBlob = { entries: {}, backendIndex: {} };
|
||||||
|
|
||||||
|
const rawIndex = storage.getString(LEGACY_INDEX_KEY);
|
||||||
|
if (!rawIndex) return blob;
|
||||||
|
|
||||||
|
const ids: string[] = JSON.parse(rawIndex);
|
||||||
|
for (const id of ids) {
|
||||||
|
const raw = storage.getString(`local_file_${id}`);
|
||||||
|
if (!raw) continue;
|
||||||
|
const entry: LocalFileEntry = JSON.parse(raw);
|
||||||
|
blob.entries[entry.id] = entry;
|
||||||
|
if (entry.backendFileId) {
|
||||||
|
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.remove(LEGACY_INDEX_KEY);
|
||||||
|
for (const id of ids) {
|
||||||
|
storage.remove(`local_file_${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const localFileRegistry = {
|
export const localFileRegistry = {
|
||||||
register(entry: LocalFileEntry) {
|
register(entry: LocalFileEntry) {
|
||||||
storage.set(entryKey(entry.id), JSON.stringify(entry));
|
const blob = loadBlob();
|
||||||
const ids = getAllIds();
|
blob.entries[entry.id] = entry;
|
||||||
if (!ids.includes(entry.id)) {
|
if (entry.backendFileId) {
|
||||||
setAllIds([entry.id, ...ids]);
|
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||||
}
|
}
|
||||||
|
saveBlob(blob);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerBatch(entries: LocalFileEntry[]) {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const blob = loadBlob();
|
||||||
|
for (const entry of entries) {
|
||||||
|
blob.entries[entry.id] = entry;
|
||||||
|
if (entry.backendFileId) {
|
||||||
|
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBlob(blob);
|
||||||
},
|
},
|
||||||
|
|
||||||
get(id: string): LocalFileEntry | undefined {
|
get(id: string): LocalFileEntry | undefined {
|
||||||
const raw = storage.getString(entryKey(id));
|
return loadBlob().entries[id];
|
||||||
if (!raw) return undefined;
|
|
||||||
return JSON.parse(raw) as LocalFileEntry;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getByBackendId(backendId: string): LocalFileEntry | undefined {
|
getByBackendId(backendId: string): LocalFileEntry | undefined {
|
||||||
const ids = getAllIds();
|
const blob = loadBlob();
|
||||||
for (const id of ids) {
|
const entryId = blob.backendIndex[backendId];
|
||||||
const entry = this.get(id);
|
if (!entryId) return undefined;
|
||||||
if (entry?.backendFileId === backendId) return entry;
|
return blob.entries[entryId];
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getAll(): LocalFileEntry[] {
|
getAll(): LocalFileEntry[] {
|
||||||
const ids = getAllIds();
|
const blob = loadBlob();
|
||||||
return ids
|
return Object.values(blob.entries);
|
||||||
.map((id) => this.get(id))
|
|
||||||
.filter((e): e is LocalFileEntry => e !== undefined);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update(id: string, updates: Partial<LocalFileEntry>) {
|
update(id: string, updates: Partial<LocalFileEntry>) {
|
||||||
const existing = this.get(id);
|
const blob = loadBlob();
|
||||||
|
const existing = blob.entries[id];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
|
if (existing.backendFileId && updates.backendFileId === undefined && updates.syncStatus === 'cloud') {
|
||||||
|
delete blob.backendIndex[existing.backendFileId];
|
||||||
|
}
|
||||||
|
|
||||||
const updated = { ...existing, ...updates };
|
const updated = { ...existing, ...updates };
|
||||||
storage.set(entryKey(id), JSON.stringify(updated));
|
blob.entries[id] = updated;
|
||||||
|
if (updated.backendFileId) {
|
||||||
|
blob.backendIndex[updated.backendFileId] = id;
|
||||||
|
}
|
||||||
|
saveBlob(blob);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSyncStatus(id: string, syncStatus: SyncStatus) {
|
updateSyncStatus(id: string, syncStatus: SyncStatus) {
|
||||||
@@ -66,17 +121,26 @@ export const localFileRegistry = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
remove(id: string) {
|
remove(id: string) {
|
||||||
storage.remove(entryKey(id));
|
const blob = loadBlob();
|
||||||
const ids = getAllIds().filter((i) => i !== id);
|
const entry = blob.entries[id];
|
||||||
setAllIds(ids);
|
if (entry?.backendFileId) {
|
||||||
|
delete blob.backendIndex[entry.backendFileId];
|
||||||
|
}
|
||||||
|
delete blob.entries[id];
|
||||||
|
saveBlob(blob);
|
||||||
},
|
},
|
||||||
|
|
||||||
removeByBackendId(backendId: string) {
|
removeByBackendId(backendId: string) {
|
||||||
const entry = this.getByBackendId(backendId);
|
const blob = loadBlob();
|
||||||
if (entry) this.remove(entry.id);
|
const entryId = blob.backendIndex[backendId];
|
||||||
|
if (entryId) {
|
||||||
|
delete blob.entries[entryId];
|
||||||
|
delete blob.backendIndex[backendId];
|
||||||
|
saveBlob(blob);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
count(): number {
|
count(): number {
|
||||||
return getAllIds().length;
|
return Object.keys(loadBlob().entries).length;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
import { FileItem } from '../types';
|
||||||
|
|
||||||
|
const storage = createMMKV({ id: 'vaultdrop-metadata' });
|
||||||
|
|
||||||
|
const FILES_KEY = 'backend_files_cache';
|
||||||
|
const UPDATED_AT_KEY = 'cache_updated_at';
|
||||||
|
const STALE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface CachedFiles {
|
||||||
|
files: FileItem[];
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadataCache = {
|
||||||
|
getFiles(): CachedFiles | null {
|
||||||
|
const raw = storage.getString(FILES_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as CachedFiles;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setFiles(files: FileItem[], page: number, total: number) {
|
||||||
|
const data: CachedFiles = { files, page, total };
|
||||||
|
storage.set(FILES_KEY, JSON.stringify(data));
|
||||||
|
storage.set(UPDATED_AT_KEY, Date.now());
|
||||||
|
},
|
||||||
|
|
||||||
|
isStale(): boolean {
|
||||||
|
const raw = storage.getString(UPDATED_AT_KEY);
|
||||||
|
if (!raw) return true;
|
||||||
|
const updatedAt = Number(raw);
|
||||||
|
return Date.now() - updatedAt > STALE_MS;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
storage.remove(FILES_KEY);
|
||||||
|
storage.remove(UPDATED_AT_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
|
||||||
|
const storage = createMMKV({ id: 'vaultdrop-thumbnails' });
|
||||||
|
|
||||||
|
const BLOB_KEY = 'thumbnail_urls';
|
||||||
|
const STALE_MS = 50 * 60 * 1000;
|
||||||
|
|
||||||
|
interface ThumbnailCacheEntry {
|
||||||
|
url: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memoryCache: Record<string, ThumbnailCacheEntry> | null = null;
|
||||||
|
|
||||||
|
function loadCache(): Record<string, ThumbnailCacheEntry> {
|
||||||
|
if (memoryCache) return memoryCache;
|
||||||
|
const raw = storage.getString(BLOB_KEY);
|
||||||
|
memoryCache = raw ? JSON.parse(raw) : {};
|
||||||
|
return memoryCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCache(cache: Record<string, ThumbnailCacheEntry>) {
|
||||||
|
memoryCache = cache;
|
||||||
|
storage.set(BLOB_KEY, JSON.stringify(cache));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const thumbnailCache = {
|
||||||
|
get(fileId: string): string | null {
|
||||||
|
const cache = loadCache();
|
||||||
|
const entry = cache[fileId];
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
delete cache[fileId];
|
||||||
|
saveCache(cache);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.url;
|
||||||
|
},
|
||||||
|
|
||||||
|
set(fileId: string, url: string, expiresAt: number) {
|
||||||
|
const cache = loadCache();
|
||||||
|
cache[fileId] = { url, expiresAt };
|
||||||
|
saveCache(cache);
|
||||||
|
},
|
||||||
|
|
||||||
|
setBatch(entries: Array<{ fileId: string; url: string; expiresAt: number }>) {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const cache = loadCache();
|
||||||
|
for (const e of entries) {
|
||||||
|
cache[e.fileId] = { url: e.url, expiresAt: e.expiresAt };
|
||||||
|
}
|
||||||
|
saveCache(cache);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(fileId: string) {
|
||||||
|
const cache = loadCache();
|
||||||
|
delete cache[fileId];
|
||||||
|
saveCache(cache);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
memoryCache = {};
|
||||||
|
storage.remove(BLOB_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -117,7 +117,7 @@ export interface RefreshResponse {
|
|||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SyncStatus = 'local' | 'synced' | 'cloud';
|
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
|
||||||
|
|
||||||
export interface LocalFileEntry {
|
export interface LocalFileEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user