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)
+36 -21
View File
@@ -1,35 +1,50 @@
package config
import "os"
import (
"fmt"
"os"
)
type Config struct {
Port string
DatabaseURL string
OCREndpoint string
UploadDir string
HMACSecret string
ServerHost string
PASETOKey string
LibreOfficePath string
PdftoppmPath string
ThumbnailDir string
Port string
DatabaseURL string
OCREndpoint string
UploadDir string
HMACSecret string
ServerHost string
PASETOKey string
LibreOfficePath string
PdftoppmPath string
ThumbnailDir string
URLExpiryMinutes int
}
func Load() *Config {
return &Config{
Port: envOr("PORT", "8080"),
DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"),
OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"),
UploadDir: envOr("UPLOAD_DIR", "./uploads"),
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
LibreOfficePath: envOr("LIBREOFFICE_PATH", "/usr/bin/libreoffice"),
PdftoppmPath: envOr("PDFTOPPM_PATH", "/usr/bin/pdftoppm"),
ThumbnailDir: envOr("THUMBNAIL_DIR", "./uploads/thumbnails"),
Port: envOr("PORT", "8080"),
DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"),
OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"),
UploadDir: envOr("UPLOAD_DIR", "./uploads"),
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
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 {
+14 -6
View File
@@ -9,12 +9,20 @@ import (
)
type URLService struct {
secret string
serverHost string
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"
]
}
}
+31 -18
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)}
/>
))}
</View>
<FileGroup
groupFiles={groupFiles}
selectedIds={selectedIds}
onItemPress={handleItemPress}
onItemLongPress={handleItemLongPress}
/>
</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 timeout = setTimeout(() => {
checkAndSync();
}, 5_000);
const interval = setInterval(checkAndSync, 30_000);
checkAndSync();
return () => clearInterval(interval);
return () => { clearTimeout(timeout); clearInterval(interval); };
}, [checkAndSync]);
return { triggerSync: checkAndSync };
+5 -11
View File
@@ -74,10 +74,9 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
});
}
return files;
} catch (err) {
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
return [];
}
} catch (err) {
return [];
}
}
export function useDeviceFiles() {
@@ -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;
}
memoryCache = migrateFromLegacy();
saveBlob(memoryCache);
return memoryCache;
}
function entryKey(id: string): string {
return `local_file_${id}`;
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;