downloadPath method

Future<void> downloadPath(
  1. String remotePath, {
  2. String? localDestination,
  3. required bool recursive,
  4. required String onConflict,
  5. required bool preserveTimestamps,
  6. required List<String> include,
  7. required List<String> exclude,
  8. required String batchId,
  9. Map<String, dynamic>? initialBatchState,
  10. required Future<void> saveStateCallback(
    1. Map<String, dynamic>
    ),
  11. int maxWorkers = kDefaultFileConcurrency,
})

Batch download by path with recursive support and resume.

Implementation

Future<void> downloadPath(
  String remotePath, {
  String? localDestination,
  required bool recursive,
  required String onConflict,
  required bool preserveTimestamps,
  required List<String> include,
  required List<String> exclude,
  required String batchId,
  Map<String, dynamic>? initialBatchState,
  required Future<void> Function(Map<String, dynamic>) saveStateCallback,
  int maxWorkers = kDefaultFileConcurrency,
}) async {
  final itemInfo = await drive.resolvePath(remotePath);

  // Handle single file
  if (itemInfo['type'] == 'file') {
    final filename = p.basename(remotePath);
    if (!shouldIncludeFile(filename, include, exclude)) return;

    final localPath = localDestination != null &&
            FileSystemEntity.isDirectorySync(localDestination)
        ? p.join(localDestination, filename)
        : (localDestination ?? filename);

    if (File(localPath).existsSync() && onConflict == 'skip') {
      print('โญ๏ธ  Skipping: $filename (exists)');
      return;
    }

    print('๐Ÿ“ฅ Downloading: $filename');
    await downloadFile(itemInfo['uuid'], savePath: localPath);
    print('โœ… Downloaded: $localPath');
    return;
  }

  // Handle folder
  if (itemInfo['type'] != 'folder') throw Exception("Unknown type");
  if (!recursive) throw Exception("Use -r for recursive download");

  final baseDestPath =
      localDestination ?? (itemInfo['metadata']?['name'] ?? 'download');
  await Directory(baseDestPath).create(recursive: true);

  Map<String, dynamic> batchState;
  List<dynamic> tasks;

  if (initialBatchState != null) {
    print("๐Ÿ”„ Resuming batch...");
    batchState = initialBatchState;
    tasks = batchState['tasks'];
  } else {
    print("๐Ÿ” Building task list (Fast)...");
    tasks = [];

    final treeData = await drive.getFlatFolderTree(itemInfo['uuid']);
    final rawFolders = treeData['folders'] as List? ?? [];
    final rawFiles =
        (treeData['files'] as List?) ?? (treeData['uploads'] as List?) ?? [];

    final folderMap = <String, Map<String, dynamic>>{};
    for (var f in rawFolders) {
      try {
        String uuid, encName, parent;
        if (f is List) {
          if (f.length < 3) continue;
          uuid = f[0];
          encName = f[1];
          parent = f[2];
        } else {
          if (f['deleted'] == true || f['trash'] == true) continue;
          uuid = f['uuid'];
          encName = f['name'];
          parent = f['parent'];
        }
        var decName = await crypto.tryDecrypt(encName, masterKeys);
        if (decName.startsWith('{')) {
          decName = json.decode(decName)['name'];
        }
        folderMap[uuid] = {'name': decName, 'parent': parent};
      } catch (_) {}
    }

    String? getRelPath(String? parentUuid) {
      var parts = <String>[];
      var curr = parentUuid;
      var seen = <String>{};
      while (curr != null && curr != itemInfo['uuid']) {
        if (seen.contains(curr)) return null;
        seen.add(curr);
        if (!folderMap.containsKey(curr)) return null;
        final f = folderMap[curr]!;
        parts.add(f['name']);
        curr = f['parent'];
      }
      if (curr == null && itemInfo['uuid'] != 'root') return null;
      return parts.reversed.join(Platform.pathSeparator);
    }

    for (var f in rawFiles) {
      try {
        String uuid, encMeta, parent;
        if (f is List) {
          if (f.length < 6) continue;
          uuid = f[0];
          parent = f[4];
          encMeta = f[5];
        } else {
          if (f['deleted'] == true || f['trash'] == true) continue;
          uuid = f['uuid'];
          parent = f['parent'];
          encMeta = f['metadata'];
        }

        final decMeta = await crypto.tryDecrypt(encMeta, masterKeys);
        final meta = json.decode(decMeta);
        final filename = meta['name'];
        final lastMod = meta['lastModified'] ?? 0;

        if (!shouldIncludeFile(filename, include, exclude)) continue;

        var relDir = getRelPath(parent);
        if (parent == itemInfo['uuid'])
          relDir = '';
        else if (relDir == null) continue;

        final localPath = p.join(baseDestPath, relDir, filename);
        tasks.add({
          'remoteUuid': uuid,
          'localPath': localPath,
          'status': 'pending',
          'remoteModificationTime': lastMod,
        });
      } catch (e) {
        api.log("File parse error: $e");
      }
    }

    batchState = {
      'operationType': 'download',
      'remotePath': remotePath,
      'localDestination': baseDestPath,
      'tasks': tasks
    };
    await saveStateCallback(batchState);
    print("๐Ÿ“ Task list: ${tasks.length} files");
  }

  // Execution
  int successCount = 0;
  int skippedCount = 0;
  int errorCount = 0;
  int completedPreviously = 0;
  int processed = 0;
  final totalTasks = tasks.length;

  // Step 2: whole FILES downloaded at once. Capped at pending count, floored
  // at 1 (single file / maxWorkers<=1 โ†’ sequential path).
  final pending = [
    for (final t in tasks)
      if ((t as Map<String, dynamic>)['status'] != 'completed') t
  ];
  final effectiveWorkers =
      max(1, min(maxWorkers, pending.isEmpty ? 1 : pending.length));

  // Serialize the shared (whole-batchState) async saves under a 1-permit mutex.
  final saveMutex = ChunkSemaphore(1);
  Future<void> saveState() async {
    await saveMutex.acquire();
    try {
      await saveStateCallback(batchState);
    } finally {
      saveMutex.release();
    }
  }

  void tally(String token) {
    switch (token) {
      case 'completed':
        successCount++;
        break;
      case 'skipped':
        skippedCount++;
        break;
      case 'error':
        errorCount++;
        break;
      case 'already':
        completedPreviously++;
        break;
    }
    processed++;
    if (!api.debugMode) {
      final pct = totalTasks > 0
          ? (processed / totalTasks * 100).toStringAsFixed(1)
          : '0.0';
      stdout.write('\rDown: $processed/$totalTasks files ($pct%)  ');
    }
  }

  if (effectiveWorkers <= 1) {
    for (final t in tasks) {
      tally(await _downloadTask(
        t as Map<String, dynamic>,
        onConflict: onConflict,
        preserveTimestamps: preserveTimestamps,
        saveState: saveState,
        globalChunkSlots: null,
      ));
    }
  } else {
    final globalChunkSlots = ChunkSemaphore(kGlobalMaxInflightChunks);
    print(
        "  ๐Ÿงต Downloading ${pending.length} file(s) with $effectiveWorkers worker(s)");
    await runWithConcurrency(tasks, effectiveWorkers, (t) async {
      tally(await _downloadTask(
        t as Map<String, dynamic>,
        onConflict: onConflict,
        preserveTimestamps: preserveTimestamps,
        saveState: saveState,
        globalChunkSlots: globalChunkSlots,
      ));
    });
  }

  print('\n' + '=' * 40);
  print('๐Ÿ“Š Download Summary:');
  if (completedPreviously > 0) print('  โœ… Previous: $completedPreviously');
  print('  โœ… Downloaded: $successCount');
  print('  โญ๏ธ  Skipped: $skippedCount');
  print('  โŒ Errors: $errorCount');
  print('=' * 40);
}