page + upload queue + folders

This commit is contained in:
m
2026-07-29 22:16:47 +02:00
parent 470474011d
commit 881767cfec
11 changed files with 248 additions and 41 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ func main() {
conversionSvc.Start(cfg.ConversionWorkers) conversionSvc.Start(cfg.ConversionWorkers)
defer conversionSvc.Stop() defer conversionSvc.Stop()
h := handler.New(resourceSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc, rebacSvc, placementSvc, syncSvc, eventBroker) h := handler.New(database, resourceSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc, rebacSvc, placementSvc, syncSvc, eventBroker)
r := gin.Default() r := gin.Default()
handler.SetupRoutes(r, h, authSvc) handler.SetupRoutes(r, h, authSvc)
+12 -2
View File
@@ -56,12 +56,22 @@ SELECT * FROM resources
WHERE checksum = $1 AND is_folder = false AND owner_id = $2 WHERE checksum = $1 AND is_folder = false AND owner_id = $2
LIMIT 1; LIMIT 1;
-- name: CountResourcesByOwner :one
SELECT COUNT(*) FROM resources
WHERE owner_id = $1 AND parent_resource_id IS NULL;
-- name: ListResourcesByOwner :many -- name: ListResourcesByOwner :many
SELECT * FROM resources SELECT * FROM resources
WHERE owner_id = $1 AND parent_resource_id IS NULL WHERE owner_id = $1 AND parent_resource_id IS NULL
ORDER BY created_at DESC; ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CountResourcesByParentAndOwner :one
SELECT COUNT(*) FROM resources
WHERE parent_resource_id = $1 AND owner_id = $2;
-- name: ListResourcesByParentAndOwner :many -- name: ListResourcesByParentAndOwner :many
SELECT * FROM resources SELECT * FROM resources
WHERE parent_resource_id = $1 AND owner_id = $2 WHERE parent_resource_id = $1 AND owner_id = $2
ORDER BY is_folder DESC, created_at DESC; ORDER BY is_folder DESC, created_at DESC
LIMIT $3 OFFSET $4;
+47 -3
View File
@@ -13,6 +13,35 @@ import (
"github.com/lib/pq" "github.com/lib/pq"
) )
const countResourcesByOwner = `-- name: CountResourcesByOwner :one
SELECT COUNT(*) FROM resources
WHERE owner_id = $1 AND parent_resource_id IS NULL
`
func (q *Queries) CountResourcesByOwner(ctx context.Context, ownerID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countResourcesByOwner, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const countResourcesByParentAndOwner = `-- name: CountResourcesByParentAndOwner :one
SELECT COUNT(*) FROM resources
WHERE parent_resource_id = $1 AND owner_id = $2
`
type CountResourcesByParentAndOwnerParams struct {
ParentResourceID uuid.NullUUID `json:"parent_resource_id"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) CountResourcesByParentAndOwner(ctx context.Context, arg CountResourcesByParentAndOwnerParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countResourcesByParentAndOwner, arg.ParentResourceID, arg.OwnerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createFolder = `-- name: CreateFolder :one const createFolder = `-- name: CreateFolder :one
INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at) INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at)
VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
@@ -323,10 +352,17 @@ const listResourcesByOwner = `-- name: ListResourcesByOwner :many
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
WHERE owner_id = $1 AND parent_resource_id IS NULL WHERE owner_id = $1 AND parent_resource_id IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 OFFSET $3
` `
func (q *Queries) ListResourcesByOwner(ctx context.Context, ownerID uuid.UUID) ([]Resource, error) { type ListResourcesByOwnerParams struct {
rows, err := q.db.QueryContext(ctx, listResourcesByOwner, ownerID) OwnerID uuid.UUID `json:"owner_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListResourcesByOwner(ctx context.Context, arg ListResourcesByOwnerParams) ([]Resource, error) {
rows, err := q.db.QueryContext(ctx, listResourcesByOwner, arg.OwnerID, arg.Limit, arg.Offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -364,15 +400,23 @@ const listResourcesByParentAndOwner = `-- name: ListResourcesByParentAndOwner :m
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
WHERE parent_resource_id = $1 AND owner_id = $2 WHERE parent_resource_id = $1 AND owner_id = $2
ORDER BY is_folder DESC, created_at DESC ORDER BY is_folder DESC, created_at DESC
LIMIT $3 OFFSET $4
` `
type ListResourcesByParentAndOwnerParams struct { type ListResourcesByParentAndOwnerParams struct {
ParentResourceID uuid.NullUUID `json:"parent_resource_id"` ParentResourceID uuid.NullUUID `json:"parent_resource_id"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
} }
func (q *Queries) ListResourcesByParentAndOwner(ctx context.Context, arg ListResourcesByParentAndOwnerParams) ([]Resource, error) { func (q *Queries) ListResourcesByParentAndOwner(ctx context.Context, arg ListResourcesByParentAndOwnerParams) ([]Resource, error) {
rows, err := q.db.QueryContext(ctx, listResourcesByParentAndOwner, arg.ParentResourceID, arg.OwnerID) rows, err := q.db.QueryContext(ctx, listResourcesByParentAndOwner,
arg.ParentResourceID,
arg.OwnerID,
arg.Limit,
arg.Offset,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+4 -1
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"database/sql"
"github.com/vaultdrop/backend/internal/auth" "github.com/vaultdrop/backend/internal/auth"
"github.com/vaultdrop/backend/internal/service" "github.com/vaultdrop/backend/internal/service"
) )
@@ -17,6 +19,7 @@ type Handler struct {
} }
func New( func New(
database *sql.DB,
resourceSvc *service.ResourceService, resourceSvc *service.ResourceService,
ocrSvc *service.OCRService, ocrSvc *service.OCRService,
urlSvc *service.URLService, urlSvc *service.URLService,
@@ -36,7 +39,7 @@ func New(
broker: eventBroker, broker: eventBroker,
}, },
OCR: &OCRHandler{ocr: ocrSvc, resources: resourceSvc}, OCR: &OCRHandler{ocr: ocrSvc, resources: resourceSvc},
Health: &HealthHandler{ocr: ocrSvc}, Health: &HealthHandler{db: database, ocr: ocrSvc},
Auth: authHandler, Auth: authHandler,
Share: &ShareHandler{rebac: rebacSvc}, Share: &ShareHandler{rebac: rebacSvc},
Device: &DeviceHandler{placement: placementSvc}, Device: &DeviceHandler{placement: placementSvc},
+31 -1
View File
@@ -1,14 +1,44 @@
package handler package handler
import ( import (
"database/sql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api" "github.com/vaultdrop/backend/pkg/api"
) )
type HealthHandler struct { type HealthHandler struct {
db *sql.DB
ocr interface{ HealthCheck() error } ocr interface{ HealthCheck() error }
} }
func (h *HealthHandler) Check(c *gin.Context) { func (h *HealthHandler) Check(c *gin.Context) {
api.Success(c, gin.H{"status": "healthy"}) checks := gin.H{}
dbErr := h.db.Ping()
if dbErr != nil {
checks["database"] = "error: " + dbErr.Error()
} else {
checks["database"] = "ok"
}
ocrErr := h.ocr.HealthCheck()
if ocrErr != nil {
checks["ocr"] = "error: " + ocrErr.Error()
} else {
checks["ocr"] = "ok"
}
status := "healthy"
for _, v := range checks {
if v != "ok" {
status = "degraded"
break
}
}
api.Success(c, gin.H{
"status": status,
"checks": checks,
})
} }
+25 -4
View File
@@ -65,11 +65,31 @@ func (h *ResourceHandler) Upload(c *gin.Context) {
api.Success(c, results) api.Success(c, results)
} }
func parsePagination(c *gin.Context) (page, limit int) {
page = 1
limit = 20
if p := c.Query("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
if n > 100 {
n = 100
}
limit = n
}
}
return
}
func (h *ResourceHandler) List(c *gin.Context) { func (h *ResourceHandler) List(c *gin.Context) {
userID := c.GetString(auth.UserIDKey) userID := c.GetString(auth.UserIDKey)
thumbnailQuality := c.Query("thumbnail") thumbnailQuality := c.Query("thumbnail")
page, limit := parsePagination(c)
resources, err := h.resources.List(userID) resources, total, err := h.resources.List(userID, page, limit)
if err != nil { if err != nil {
log.Printf("ERROR List resources: %v", err) log.Printf("ERROR List resources: %v", err)
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources") api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources")
@@ -132,7 +152,7 @@ func (h *ResourceHandler) List(c *gin.Context) {
} }
} }
api.Paginated(c, resp, 1, len(resp)) api.Paginated(c, resp, page, total)
} }
func (h *ResourceHandler) Download(c *gin.Context) { func (h *ResourceHandler) Download(c *gin.Context) {
@@ -335,8 +355,9 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) {
userID := c.GetString(auth.UserIDKey) userID := c.GetString(auth.UserIDKey)
parentID := c.Param("id") parentID := c.Param("id")
thumbnailQuality := c.Query("thumbnail") thumbnailQuality := c.Query("thumbnail")
page, limit := parsePagination(c)
resources, err := h.resources.ListResourcesByParentID(parentID, userID) resources, total, err := h.resources.ListResourcesByParentID(parentID, userID, page, limit)
if err != nil { if err != nil {
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources in folder") api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources in folder")
return return
@@ -396,7 +417,7 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) {
} }
} }
api.Success(c, resp) api.Paginated(c, resp, page, total)
} }
func (h *ResourceHandler) GetVariants(c *gin.Context) { func (h *ResourceHandler) GetVariants(c *gin.Context) {
+33 -10
View File
@@ -147,22 +147,33 @@ func (s *ResourceService) ensureServerPlacementQtx(q *db.Queries, resourceID, ow
return placement, nil return placement, nil
} }
func (s *ResourceService) List(ownerID string) ([]model.Resource, error) { func (s *ResourceService) List(ownerID string, page, limit int) ([]model.Resource, int, error) {
ownerUUID, _ := uuid.Parse(ownerID) ownerUUID, _ := uuid.Parse(ownerID)
dbResources, err := s.queries.ListResourcesByOwner(context.Background(), ownerUUID)
total, err := s.queries.CountResourcesByOwner(context.Background(), ownerUUID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list resources: %w", err) return nil, 0, fmt.Errorf("count resources: %w", err)
}
offset := (page - 1) * limit
dbResources, err := s.queries.ListResourcesByOwner(context.Background(), db.ListResourcesByOwnerParams{
OwnerID: ownerUUID,
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return nil, 0, fmt.Errorf("list resources: %w", err)
} }
resources := make([]model.Resource, len(dbResources)) resources := make([]model.Resource, len(dbResources))
for i, r := range dbResources { for i, r := range dbResources {
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID) tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err) return nil, 0, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
} }
resources[i] = dbResourceToModel(r, tags) resources[i] = dbResourceToModel(r, tags)
} }
return resources, nil return resources, int(total), nil
} }
func (s *ResourceService) Get(id string) (*model.Resource, error) { func (s *ResourceService) Get(id string) (*model.Resource, error) {
@@ -309,25 +320,37 @@ func (s *ResourceService) ListFolders(ownerID string) ([]model.Resource, error)
return folders, nil return folders, nil
} }
func (s *ResourceService) ListResourcesByParentID(parentID, ownerID string) ([]model.Resource, error) { func (s *ResourceService) ListResourcesByParentID(parentID, ownerID string, page, limit int) ([]model.Resource, int, error) {
parentUUID, _ := uuid.Parse(parentID) parentUUID, _ := uuid.Parse(parentID)
ownerUUID, _ := uuid.Parse(ownerID) ownerUUID, _ := uuid.Parse(ownerID)
dbResources, err := s.queries.ListResourcesByParentAndOwner(context.Background(), db.ListResourcesByParentAndOwnerParams{
total, err := s.queries.CountResourcesByParentAndOwner(context.Background(), db.CountResourcesByParentAndOwnerParams{
ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true}, ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true},
OwnerID: ownerUUID, OwnerID: ownerUUID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("list resources by parent: %w", err) return nil, 0, fmt.Errorf("count resources by parent: %w", err)
}
offset := (page - 1) * limit
dbResources, err := s.queries.ListResourcesByParentAndOwner(context.Background(), db.ListResourcesByParentAndOwnerParams{
ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true},
OwnerID: ownerUUID,
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return nil, 0, fmt.Errorf("list resources by parent: %w", err)
} }
resources := make([]model.Resource, len(dbResources)) resources := make([]model.Resource, len(dbResources))
for i, r := range dbResources { for i, r := range dbResources {
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID) tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err) return nil, 0, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
} }
resources[i] = dbResourceToModel(r, tags) resources[i] = dbResourceToModel(r, tags)
} }
return resources, nil return resources, int(total), nil
} }
func (s *ResourceService) GetVariantsByResourceID(resourceID string) ([]model.Variant, error) { func (s *ResourceService) GetVariantsByResourceID(resourceID string) ([]model.Variant, error) {
+40 -1
View File
@@ -22,6 +22,7 @@ import { deleteAsync } from 'expo-file-system/legacy';
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS; const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
const PAGE_SIZE = 100;
type RootStackParamList = { type RootStackParamList = {
Folder: { folderId: string; folderName: string }; Folder: { folderId: string; folderName: string };
@@ -75,7 +76,8 @@ export function FolderScreen() {
const route = useRoute<FolderRouteProp>(); const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params; const { folderId, folderName } = route.params;
const { data, isLoading } = useFiles(folderId); const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = useFiles(folderId, page, PAGE_SIZE);
const deleteFile = useDeleteFile(); const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace(); const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -87,6 +89,17 @@ export function FolderScreen() {
const { data: foldersData } = useFolders(); const { data: foldersData } = useFolders();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const loadMore = useCallback(() => {
if (isFetching) return;
const total = data?.meta?.total ?? 0;
const loaded = data?.data?.length ?? 0;
if (loaded < total) {
setPage((p) => p + 1);
}
}, [isFetching, data?.meta?.total, data?.data?.length]);
const hasMore = (data?.data?.length ?? 0) > 0 && (data?.data?.length ?? 0) < (data?.meta?.total ?? 0);
const [tagModalVisible, setTagModalVisible] = useState(false); const [tagModalVisible, setTagModalVisible] = useState(false);
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag'); const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
@@ -246,12 +259,25 @@ export function FolderScreen() {
data={files} data={files}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.empty}> <View style={styles.empty}>
<MaterialIcons name="folder-open" size={48} color="#ccc" /> <MaterialIcons name="folder-open" size={48} color="#ccc" />
<Text style={styles.emptyText}>Dossier vide</Text> <Text style={styles.emptyText}>Dossier vide</Text>
</View> </View>
} }
ListFooterComponent={
isFetching ? (
<View style={styles.footer}>
<Text style={styles.footerText}>Chargement...</Text>
</View>
) : hasMore ? (
<TouchableOpacity style={styles.footer} onPress={loadMore}>
<Text style={styles.footerLink}>Charger plus</Text>
</TouchableOpacity>
) : null
}
renderItem={({ item: file }) => ( renderItem={({ item: file }) => (
<FolderGridItem <FolderGridItem
file={file} file={file}
@@ -496,4 +522,17 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#333', color: '#333',
}, },
footer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
color: '#999',
},
footerLink: {
fontSize: 14,
color: '#1976D2',
fontWeight: '600',
},
}); });
+4 -4
View File
@@ -39,15 +39,15 @@ function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) { export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
const queryKey = parentId const queryKey = parentId
? ['resources', parentId] ? ['resources', parentId, page, limit]
: ['resources', 'root', page, limit]; : ['resources', 'root', page, limit];
return useQuery({ return useQuery({
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
if (parentId) { if (parentId) {
const backendRes = await apiClient.get<{ data: FileItem[] }>( const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?thumbnail=thumbnail_small`, `${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
); );
fileStore.mergeFromBackend( fileStore.mergeFromBackend(
backendRes.data.map((f) => ({ backendRes.data.map((f) => ({
@@ -69,7 +69,7 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
const children = fileStore.getChildrenByParent(parentId); const children = fileStore.getChildrenByParent(parentId);
return { return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean), data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: children.length }, meta: { page, total: backendRes.meta?.total ?? children.length },
}; };
} }
+14 -3
View File
@@ -19,7 +19,10 @@ export function usePullSync() {
try { try {
setIsSyncing(true); setIsSyncing(true);
const res = await apiClient.get<{ data: Array<{ let page = 1;
const limit = 100;
let total = 0;
const backendResources: Array<{
id: string; id: string;
name: string; name: string;
mimeType: string; mimeType: string;
@@ -28,9 +31,17 @@ export function usePullSync() {
url?: string; url?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
ownerId?: string; ownerId?: string;
}> }>(`${ENDPOINTS.RESOURCES}?page=1&limit=100&thumbnail=thumbnail_small`); }> = [];
do {
const res = await apiClient.get<{ data: Array<typeof backendResources[number]>; meta?: { total: number } }>(
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
backendResources.push(...(res.data ?? []));
total = res.meta?.total ?? res.data.length;
page++;
} while (backendResources.length < total);
const backendResources = res.data ?? [];
const registry = fileStore.getAllSynced(); const registry = fileStore.getAllSynced();
const existingBackendIds = new Set( const existingBackendIds = new Set(
registry.filter((e) => e.backendId).map((e) => e.backendId) registry.filter((e) => e.backendId).map((e) => e.backendId)
+26
View File
@@ -7,6 +7,9 @@ import { ApiError, UploadError } from '../types';
export type UploadFile = { uri: string; type: string; name: string }; export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string }; export type UploadResult = { name: string; id: string };
export const UPLOAD_MAX_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 1000;
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error'; export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
export type UploadTask = { export type UploadTask = {
@@ -16,6 +19,7 @@ export type UploadTask = {
progress: number; progress: number;
result?: UploadResult; result?: UploadResult;
error?: string; error?: string;
retryCount: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
}; };
@@ -33,6 +37,7 @@ function serialize(task: UploadTask): unknown {
progress: task.progress, progress: task.progress,
result: task.result ?? null, result: task.result ?? null,
error: task.error ?? null, error: task.error ?? null,
retryCount: task.retryCount,
createdAt: task.createdAt, createdAt: task.createdAt,
updatedAt: task.updatedAt, updatedAt: task.updatedAt,
}; };
@@ -50,6 +55,7 @@ function deserialize(data: unknown): UploadTask | null {
progress: d.progress as number, progress: d.progress as number,
result: d.result ? (d.result as UploadResult) : undefined, result: d.result ? (d.result as UploadResult) : undefined,
error: d.error ? (d.error as string) : undefined, error: d.error ? (d.error as string) : undefined,
retryCount: (d.retryCount as number) ?? 0,
createdAt: d.createdAt as number, createdAt: d.createdAt as number,
updatedAt: d.updatedAt as number, updatedAt: d.updatedAt as number,
}; };
@@ -147,6 +153,7 @@ class UploadQueue {
file, file,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
retryCount: 0,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -171,6 +178,7 @@ class UploadQueue {
if (!task || task.status !== 'error') return; if (!task || task.status !== 'error') return;
task.status = 'pending'; task.status = 'pending';
task.progress = 0; task.progress = 0;
task.retryCount = 0;
task.error = undefined; task.error = undefined;
task.result = undefined; task.result = undefined;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
@@ -184,6 +192,7 @@ class UploadQueue {
if (task.status === 'error') { if (task.status === 'error') {
task.status = 'pending'; task.status = 'pending';
task.progress = 0; task.progress = 0;
task.retryCount = 0;
task.error = undefined; task.error = undefined;
task.result = undefined; task.result = undefined;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
@@ -212,6 +221,7 @@ class UploadQueue {
} }
private async runTask(task: UploadTask) { private async runTask(task: UploadTask) {
let willRetry = false;
try { try {
const fsFile = new File(task.file.uri); const fsFile = new File(task.file.uri);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -251,6 +261,18 @@ class UploadQueue {
this.notify(); this.notify();
this.scheduleCleanup(); this.scheduleCleanup();
} catch (err) { } catch (err) {
task.retryCount++;
if (task.retryCount <= UPLOAD_MAX_RETRIES) {
willRetry = true;
task.status = 'pending';
task.progress = 0;
task.error = undefined;
task.updatedAt = Date.now();
this.persist();
this.notify();
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, task.retryCount - 1);
setTimeout(() => this.processNext(), delay);
} else {
task.status = 'error'; task.status = 'error';
task.error = task.error =
err instanceof UploadError err instanceof UploadError
@@ -258,15 +280,19 @@ class UploadQueue {
: err instanceof Error : err instanceof Error
? err.message ? err.message
: 'Erreur inconnue'; : 'Erreur inconnue';
task.error += ` (${task.retryCount} tentative(s))`;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.persist(); this.persist();
this.notify(); this.notify();
}
} finally { } finally {
this.active--; this.active--;
this.notify(); this.notify();
if (!willRetry) {
this.processNext(); this.processNext();
} }
} }
}
private scheduleCleanup() { private scheduleCleanup() {
if (this.cleanupTimer) return; if (this.cleanupTimer) return;