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)
ocrSvc := service.NewOCRService(cfg, fileSvc)
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)
if err != nil {
log.Fatalf("Failed to create auth service: %v", err)
+16 -1
View File
@@ -1,6 +1,9 @@
package config
import "os"
import (
"fmt"
"os"
)
type Config struct {
Port string
@@ -13,6 +16,7 @@ type Config struct {
LibreOfficePath string
PdftoppmPath string
ThumbnailDir string
URLExpiryMinutes int
}
func Load() *Config {
@@ -27,9 +31,20 @@ func Load() *Config {
LibreOfficePath: envOr("LIBREOFFICE_PATH", "/usr/bin/libreoffice"),
PdftoppmPath: envOr("PDFTOPPM_PATH", "/usr/bin/pdftoppm"),
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 {
if v := os.Getenv(key); v != "" {
return v
+76
View File
@@ -8,6 +8,7 @@ package db
import (
"context"
"database/sql"
"time"
"github.com/lib/pq"
)
@@ -86,6 +87,81 @@ func (q *Queries) DeleteFile(ctx context.Context, id string) error {
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
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
+10
View File
@@ -45,3 +45,13 @@ WHERE id = $4;
-- name: DeleteFile :exec
DELETE FROM files
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 (
"log"
"net/http"
"os"
"path"
"strconv"
@@ -218,10 +219,22 @@ func (h *FileHandler) Get(c *gin.Context) {
func (h *FileHandler) Delete(c *gin.Context) {
id := c.Param("id")
storagePath, _ := h.files.GetStoragePath(id)
thumbnails, _ := h.files.GetThumbnailsByFileID(id)
if err := h.files.Delete(id); err != nil {
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete file")
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})
}
@@ -432,3 +445,47 @@ func (h *FileHandler) ServeThumbnail(c *gin.Context) {
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("/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))
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{
Name: file.Filename,
MimeType: file.Header.Get("Content-Type"),
@@ -280,6 +291,13 @@ func (s *FileService) GetBestThumbnail(fileID, preferredLabel string) *model.Thu
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 {
tags := make([]model.Tag, len(dbTags))
for i, t := range dbTags {
+12 -4
View File
@@ -11,10 +11,18 @@ import (
type URLService struct {
secret string
serverHost string
expiryDuration time.Duration
}
func NewURLService(secret, serverHost string) *URLService {
return &URLService{secret: secret, serverHost: serverHost}
func NewURLService(secret, serverHost string, expiryMinutes int) *URLService {
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 {
@@ -25,7 +33,7 @@ func (s *URLService) sign(fileID string, expires int64) 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)
return fmt.Sprintf(
@@ -38,7 +46,7 @@ func (s *URLService) GenerateDownloadURL(fileUUID 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)
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.",
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
}
]
],
"expo-image"
]
}
}
+30 -17
View File
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
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 { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { FileItem, isFolder } from '../types';
@@ -84,9 +84,7 @@ function formatDateLabel(key: string): string {
return key.charAt(0).toUpperCase() + key.slice(1);
}
function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
const { data, isLoading } = useFileImage(file.id);
const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
return (
<TouchableOpacity
style={[styles.gridItem, selected && styles.gridItemSelected]}
@@ -96,12 +94,11 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
activeOpacity={0.7}
>
<FileThumbnail
uri={data?.data?.url ?? file.localUri}
uri={file.url ?? file.localUri}
thumbnailUrl={file.thumbnailUrl}
mimeType={file.mimeType}
fileName={file.name}
size={ITEM_SIZE}
isLoading={isLoading}
syncStatus={file.syncStatus}
/>
{selected && (
@@ -114,7 +111,28 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
</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 {
if (!query) return true;
@@ -447,17 +465,12 @@ export function HomeScreen() {
<Text style={styles.sectionTitle}>{label}</Text>
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
</View>
<View style={styles.grid}>
{groupFiles.map((file) => (
<FileGridItem
key={file.id}
file={file}
selected={selectedIds.has(file.id)}
onPress={() => handleItemPress(file)}
onLongPress={() => handleItemLongPress(file)}
<FileGroup
groupFiles={groupFiles}
selectedIds={selectedIds}
onItemPress={handleItemPress}
onItemLongPress={handleItemLongPress}
/>
))}
</View>
</View>
);
}}
+11 -4
View File
@@ -1,5 +1,6 @@
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 type { ComponentProps } from 'react';
import { SyncStatusBadge } from './SyncStatusBadge';
@@ -40,7 +41,7 @@ interface FileThumbnailProps {
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 ext = getExtension(fileName);
@@ -57,7 +58,13 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
if (imageUri) {
return (
<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} />}
</View>
);
@@ -72,7 +79,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
{syncStatus && <SyncStatusBadge status={syncStatus} />}
</View>
);
}
});
const styles = StyleSheet.create({
container: {
+2
View File
@@ -10,8 +10,10 @@ interface SyncStatusBadgeProps {
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string }> = {
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)' },
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) {
+5 -7
View File
@@ -91,7 +91,6 @@ export function useAutoSync() {
if (pendingFiles.length === 0) return;
console.log(`[useAutoSync] mode=${globalMode}, upload de ${pendingFiles.length} fichier(s)`);
setIsSyncing(true);
for (const entry of pendingFiles) {
@@ -107,15 +106,12 @@ export function useAutoSync() {
backendFileId: uploaded.id,
syncStatus: 'synced',
});
console.log(`[useAutoSync] "${entry.name}" uploadé → id=${uploaded.id}`);
} catch (err) {
console.error(`[useAutoSync] échec upload "${entry.name}":`, err);
// upload failed, will retry on next cycle
}
}
queryClient.invalidateQueries({ queryKey: ['files'] });
console.log(`[useAutoSync] sync terminé`);
} finally {
setIsSyncing(false);
isRunning.current = false;
@@ -123,9 +119,11 @@ export function useAutoSync() {
}, [queryClient]);
useEffect(() => {
const interval = setInterval(checkAndSync, 30_000);
const timeout = setTimeout(() => {
checkAndSync();
return () => clearInterval(interval);
}, 5_000);
const interval = setInterval(checkAndSync, 30_000);
return () => { clearTimeout(timeout); clearInterval(interval); };
}, [checkAndSync]);
return { triggerSync: checkAndSync };
+2 -8
View File
@@ -75,7 +75,6 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
}
return files;
} catch (err) {
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
return [];
}
}
@@ -121,7 +120,6 @@ export function useDeviceFiles() {
setFiles(deviceFiles);
} catch (err) {
console.error('[useDeviceFiles] scan error:', err);
setFiles([]);
} finally {
setIsLoading(false);
@@ -132,11 +130,8 @@ export function useDeviceFiles() {
const visibleFolders = safDirectory.getVisibleFolders();
if (visibleFolders.length === 0) return;
const safFiles: DeviceFile[] = [];
for (const folder of visibleFolders) {
const folderFiles = await scanSafFolder(folder);
safFiles.push(...folderFiles);
}
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
const safFiles = results.flat();
setFiles((prev) => {
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
@@ -191,7 +186,6 @@ export function useDeviceFiles() {
});
return true;
} catch (err) {
console.error('[useDeviceFiles] pickDirectory error:', err);
return false;
}
}, []);
+18 -3
View File
@@ -2,14 +2,26 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { FileItem, PaginatedResponse } from '../types';
import { metadataCache } from '../services/metadataCache';
export function useFiles(page: number = 1, limit: number = 20) {
return useQuery({
queryKey: ['files', page, limit],
queryFn: () =>
apiClient.get<PaginatedResponse<FileItem>>(
queryFn: async () => {
const res = await apiClient.get<PaginatedResponse<FileItem>>(
`${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}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
},
});
}
@@ -48,6 +61,7 @@ export function useAddTags() {
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
},
});
}
@@ -60,6 +74,7 @@ export function useMoveFiles() {
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
},
});
}
+14 -9
View File
@@ -5,13 +5,17 @@ import { LocalFileEntry } from '../types';
export function useLocalFiles() {
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
const lastDeviceCount = useRef(0);
useEffect(() => {
if (deviceFiles.length === 0) return;
if (deviceFiles.length === lastDeviceCount.current) return;
lastDeviceCount.current = deviceFiles.length;
const newEntries: LocalFileEntry[] = [];
for (const df of deviceFiles) {
if (localFileRegistry.get(df.id)) continue;
const entry: LocalFileEntry = {
newEntries.push({
id: df.id,
localUri: df.uri,
name: df.name,
@@ -20,12 +24,15 @@ export function useLocalFiles() {
syncStatus: 'local',
createdAt: df.createdAt,
folderId: df.folderId,
};
localFileRegistry.register(entry);
});
}
if (newEntries.length > 0) {
localFileRegistry.registerBatch(newEntries);
}
}, [deviceFiles]);
const localFiles = useMemo(() => {
const registryEntries = localFileRegistry.getAll();
const merged = new Map<string, LocalFileEntry>();
for (const entry of registryEntries) {
@@ -47,10 +54,8 @@ export function useLocalFiles() {
}
}
const result = Array.from(merged.values());
console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`);
return result;
}, [deviceFiles, registryEntries]);
return Array.from(merged.values());
}, [deviceFiles]);
return {
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 { useLocalFiles } from './useLocalFiles';
import { localFileRegistry } from '../services/localFileRegistry';
import { thumbnailCache } from '../services/thumbnailCache';
import { apiClient } from '../api/client';
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
@@ -24,6 +25,7 @@ export interface UnifiedFileItem {
url?: string;
thumbnailUrl?: string;
isDeviceFile?: boolean;
duplicateOf?: string;
}
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
@@ -41,6 +43,15 @@ function getExtension(name: string): string {
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) {
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
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) {
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, {
id: bf.id,
backendFileId: bf.id,
@@ -74,7 +102,7 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
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 (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, {
id: lf.id,
backendFileId: lf.backendFileId,
@@ -138,6 +178,16 @@ export function useUnifiedFilesByParent(parentId: string) {
for (const bf of backendFiles) {
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, {
id: bf.id,
backendFileId: bf.id,
@@ -153,7 +203,7 @@ export function useUnifiedFilesByParent(parentId: string) {
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
url: bf.url,
thumbnailUrl: bf.thumbnailUrl,
thumbnailUrl: thumbUrl,
});
}
+21
View File
@@ -16,6 +16,7 @@
"expo": "~57.0.4",
"expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0",
"expo-image": "~57.0.1",
"expo-image-picker": "~57.0.2",
"expo-media-library": "~57.0.3",
"expo-print": "~57.0.0",
@@ -3082,6 +3083,26 @@
"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": {
"version": "57.0.0",
"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-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0",
"expo-image": "~57.0.1",
"expo-image-picker": "~57.0.2",
"expo-media-library": "~57.0.3",
"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 INDEX_KEY = 'local_files_index';
const BLOB_KEY = 'local_files_v2';
const LEGACY_INDEX_KEY = 'local_files_index';
function getAllIds(): string[] {
const raw = storage.getString(INDEX_KEY);
if (!raw) return [];
return JSON.parse(raw) as string[];
interface RegistryBlob {
entries: Record<string, LocalFileEntry>;
backendIndex: Record<string, string>;
}
function setAllIds(ids: string[]) {
storage.set(INDEX_KEY, JSON.stringify(ids));
let memoryCache: RegistryBlob | null = null;
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 {
return `local_file_${id}`;
memoryCache = migrateFromLegacy();
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 = {
register(entry: LocalFileEntry) {
storage.set(entryKey(entry.id), JSON.stringify(entry));
const ids = getAllIds();
if (!ids.includes(entry.id)) {
setAllIds([entry.id, ...ids]);
const blob = loadBlob();
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
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 {
const raw = storage.getString(entryKey(id));
if (!raw) return undefined;
return JSON.parse(raw) as LocalFileEntry;
return loadBlob().entries[id];
},
getByBackendId(backendId: string): LocalFileEntry | undefined {
const ids = getAllIds();
for (const id of ids) {
const entry = this.get(id);
if (entry?.backendFileId === backendId) return entry;
}
return undefined;
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (!entryId) return undefined;
return blob.entries[entryId];
},
getAll(): LocalFileEntry[] {
const ids = getAllIds();
return ids
.map((id) => this.get(id))
.filter((e): e is LocalFileEntry => e !== undefined);
const blob = loadBlob();
return Object.values(blob.entries);
},
update(id: string, updates: Partial<LocalFileEntry>) {
const existing = this.get(id);
const blob = loadBlob();
const existing = blob.entries[id];
if (!existing) return;
if (existing.backendFileId && updates.backendFileId === undefined && updates.syncStatus === 'cloud') {
delete blob.backendIndex[existing.backendFileId];
}
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) {
@@ -66,17 +121,26 @@ export const localFileRegistry = {
},
remove(id: string) {
storage.remove(entryKey(id));
const ids = getAllIds().filter((i) => i !== id);
setAllIds(ids);
const blob = loadBlob();
const entry = blob.entries[id];
if (entry?.backendFileId) {
delete blob.backendIndex[entry.backendFileId];
}
delete blob.entries[id];
saveBlob(blob);
},
removeByBackendId(backendId: string) {
const entry = this.getByBackendId(backendId);
if (entry) this.remove(entry.id);
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (entryId) {
delete blob.entries[entryId];
delete blob.backendIndex[backendId];
saveBlob(blob);
}
},
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;
}
export type SyncStatus = 'local' | 'synced' | 'cloud';
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
export interface LocalFileEntry {
id: string;