display files with cache image

This commit is contained in:
m
2026-07-27 23:02:09 +02:00
parent c5a959a27b
commit e8ceb99a5d
25 changed files with 814 additions and 118 deletions
+147
View File
@@ -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))
}
+1 -1
View File
@@ -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)
+36 -21
View File
@@ -1,35 +1,50 @@
package config package config
import "os" import (
"fmt"
"os"
)
type Config struct { type Config struct {
Port string Port string
DatabaseURL string DatabaseURL string
OCREndpoint string OCREndpoint string
UploadDir string UploadDir string
HMACSecret string HMACSecret string
ServerHost string ServerHost string
PASETOKey string PASETOKey string
LibreOfficePath string LibreOfficePath string
PdftoppmPath string PdftoppmPath string
ThumbnailDir string ThumbnailDir string
URLExpiryMinutes int
} }
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Port: envOr("PORT", "8080"), Port: envOr("PORT", "8080"),
DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"), DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"),
OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"), OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"),
UploadDir: envOr("UPLOAD_DIR", "./uploads"), UploadDir: envOr("UPLOAD_DIR", "./uploads"),
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"), HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"), ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"), PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
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
+76
View File
@@ -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
+10
View File
@@ -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;
+57
View File
@@ -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),
})
}
+2
View File
@@ -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)
} }
+18
View File
@@ -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 {
+14 -6
View File
@@ -9,12 +9,20 @@ 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
View File
@@ -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"
] ]
} }
} }
+31 -18
View File
@@ -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>
); );
}} }}
+11 -4
View File
@@ -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: {
+2
View File
@@ -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) {
+5 -7
View File
@@ -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 timeout = setTimeout(() => {
checkAndSync();
}, 5_000);
const interval = setInterval(checkAndSync, 30_000); const interval = setInterval(checkAndSync, 30_000);
checkAndSync(); return () => { clearTimeout(timeout); clearInterval(interval); };
return () => clearInterval(interval);
}, [checkAndSync]); }, [checkAndSync]);
return { triggerSync: checkAndSync }; return { triggerSync: checkAndSync };
+5 -11
View File
@@ -74,10 +74,9 @@ 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 []; }
}
} }
export function useDeviceFiles() { export function useDeviceFiles() {
@@ -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;
} }
}, []); }, []);
+18 -3
View File
@@ -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();
}, },
}); });
} }
+14 -9
View File
@@ -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,
+83
View File
@@ -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 };
}
+52 -2
View File
@@ -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,
}); });
} }
+21
View File
@@ -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",
+1
View File
@@ -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",
+98 -34
View File
@@ -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;
}
memoryCache = migrateFromLegacy();
saveBlob(memoryCache);
return memoryCache;
} }
function entryKey(id: string): string { function saveBlob(blob: RegistryBlob) {
return `local_file_${id}`; 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;
}, },
}; };
+44
View File
@@ -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);
},
};
+65
View File
@@ -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);
},
};
+1 -1
View File
@@ -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;