file_upload_api.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. import 'dart:io';
  2. import 'package:file_upload_processor/handlers/base_api.dart';
  3. import 'package:mime/mime.dart';
  4. import 'package:shelf/shelf.dart' as shelf;
  5. import 'package:path/path.dart' as path;
  6. import 'package:http_parser/http_parser.dart';
  7. import 'package:archive/archive.dart';
  8. import 'package:supabase/supabase.dart';
  9. import 'package:intl/intl.dart';
  10. class FileUploadApi extends BaseApi {
  11. FileUploadApi(shelf.Request request) : super(request);
  12. final uploadFolder = "./uploaded";
  13. String get rawFolder => "$uploadFolder/raw";
  14. String get dataFolder => "$uploadFolder/data";
  15. String workingFolder = "";
  16. String get zipFolder => "$rawFolder/$workingFolder";
  17. String get extFolder => "$dataFolder/$workingFolder";
  18. SupabaseClient getSupabaseClient(shelf.Request request) {
  19. final supabaseUrl = request.headers['supabase-url'];
  20. final authHeader = request.headers['authorization'];
  21. final token = authHeader!.substring(7); // Remove 'Bearer ' prefix
  22. return SupabaseClient(
  23. supabaseUrl!,
  24. token,
  25. );
  26. }
  27. String getTimestampedFilename(String originalFilename) {
  28. final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
  29. return '${timestamp}_$originalFilename';
  30. }
  31. Future<void> uploadToSupabase(
  32. String filePath, String filename, SupabaseClient supabaseClient,
  33. {bool timestamped = false,
  34. required String bucket,
  35. bool upsert = true}) async {
  36. try {
  37. final file = File(filePath);
  38. final timestampedFilename =
  39. timestamped ? getTimestampedFilename(filename) : filename;
  40. await supabaseClient.storage.from(bucket).upload(
  41. timestampedFilename,
  42. file,
  43. fileOptions: FileOptions(
  44. cacheControl: '3600',
  45. upsert: upsert,
  46. ),
  47. );
  48. print('+File uploaded to <$bucket>: $timestampedFilename');
  49. } catch (e) {
  50. print('!Error uploading to Supabase: $e');
  51. rethrow;
  52. }
  53. }
  54. Future<void> initializeDirectories() async {
  55. final directories = [
  56. Directory(rawFolder),
  57. Directory(dataFolder),
  58. Directory(zipFolder),
  59. Directory(extFolder),
  60. ];
  61. for (var dir in directories) {
  62. if (!await dir.exists()) {
  63. await dir.create(recursive: true);
  64. }
  65. }
  66. }
  67. bool isZipFile(List<int> bytes) {
  68. if (bytes.length < 4) return false;
  69. return bytes[0] == 0x50 &&
  70. bytes[1] == 0x4B &&
  71. bytes[2] == 0x03 &&
  72. bytes[3] == 0x04;
  73. }
  74. Future<List<String>> processZipFile(String filePath) async {
  75. List<String> files = [];
  76. final bytes = await File(filePath).readAsBytes();
  77. final archive = ZipDecoder().decodeBytes(bytes);
  78. for (final file in archive) {
  79. final filename = file.name;
  80. if (file.isFile) {
  81. final data = file.content as List<int>;
  82. final outFile = File(path.join(extFolder, filename));
  83. await outFile.parent.create(recursive: true);
  84. await outFile.writeAsBytes(data);
  85. files.add(path.join(extFolder, filename));
  86. }
  87. }
  88. return files;
  89. }
  90. @override
  91. response() async {
  92. final supabaseUrl = request.headers['supabase-url'];
  93. final authHeader = request.headers['authorization'];
  94. if (authHeader == null || !authHeader.startsWith('Bearer ')) {
  95. return shelf.Response.badRequest(
  96. body: 'Invalid or missing Authorization Bearer token');
  97. }
  98. if (supabaseUrl == null) {
  99. return shelf.Response.badRequest(
  100. body: 'Supabase URL not provided in headers');
  101. }
  102. workingFolder = DateTime.now().millisecondsSinceEpoch.toString();
  103. final supabaseClient = getSupabaseClient(request);
  104. await initializeDirectories();
  105. final contentType = request.headers['content-type'];
  106. if (contentType == null ||
  107. !contentType.toLowerCase().startsWith('multipart/form-data')) {
  108. return shelf.Response.badRequest(
  109. body: 'Content-Type must be multipart/form-data');
  110. }
  111. try {
  112. final mediaType = MediaType.parse(contentType);
  113. final boundary = mediaType.parameters['boundary'];
  114. if (boundary == null) {
  115. return shelf.Response.badRequest(body: 'Boundary not found');
  116. }
  117. final transformer = MimeMultipartTransformer(boundary);
  118. final bodyBytes = await request.read().expand((e) => e).toList();
  119. final stream = Stream.fromIterable([bodyBytes]);
  120. final parts = await transformer.bind(stream).toList();
  121. for (var part in parts) {
  122. final contentDisposition = part.headers['content-disposition'];
  123. if (contentDisposition == null) continue;
  124. final filenameMatch =
  125. RegExp(r'filename="([^"]*)"').firstMatch(contentDisposition);
  126. if (filenameMatch == null) continue;
  127. final filename = filenameMatch.group(1);
  128. if (filename == null) continue;
  129. final bytes = await part.fold<List<int>>(
  130. [],
  131. (prev, element) => [...prev, ...element],
  132. );
  133. final rawFilePath = path.join(zipFolder, filename);
  134. await File(rawFilePath).writeAsBytes(bytes);
  135. List<String> files = [];
  136. if (isZipFile(bytes)) {
  137. files.addAll(await processZipFile(rawFilePath));
  138. } else {
  139. final dataFilePath = path.join(extFolder, filename);
  140. await File(rawFilePath).copy(dataFilePath);
  141. files.add(dataFilePath);
  142. }
  143. bytes.clear();
  144. //upload to supabase storage
  145. await uploadToSupabase(rawFilePath, filename, supabaseClient,
  146. bucket: 'csvhich', timestamped: false, upsert: true);
  147. //upload to supabase storage archive timestamped
  148. await uploadToSupabase(rawFilePath, filename, supabaseClient,
  149. bucket: 'csvhich_archive', timestamped: true, upsert: false);
  150. //insert data to supabase csvhichupdates
  151. await supabaseClient
  152. .from('csvhichupdates')
  153. .insert({'filename': filename});
  154. for (var file in files) {
  155. final fileProcess = FileProcess(file, supabaseClient);
  156. await fileProcess.go(donttouchdb: false);
  157. }
  158. }
  159. return shelf.Response.ok('File processed and uploaded successfully');
  160. } catch (e) {
  161. //print('Error: $e\n$stackTrace');
  162. return shelf.Response.internalServerError(
  163. body: 'Error processing upload: $e');
  164. } finally {
  165. supabaseClient.dispose();
  166. await File(zipFolder).delete(recursive: true);
  167. await File(extFolder).delete(recursive: true);
  168. }
  169. }
  170. }
  171. class FileProcess {
  172. FileProcess(this.filepath, this.supabase);
  173. final String filepath;
  174. final SupabaseClient supabase;
  175. String get filename => filepath.replaceAll('\\', "/").split("/").last;
  176. final Map<String, String> tables = {
  177. "secondprgtype.txt": "aclegs_csv",
  178. "ExportPGRGPNmois.txt": "pnlegs_csv",
  179. "exportPGRGPN.txt": "pnlegs_csv",
  180. "exportlicence.txt": "licences_csv",
  181. };
  182. final Map<String, List<String>> _headers = {
  183. "secondprgtype.txt": [
  184. "leg_no",
  185. "fn_carrier",
  186. "fn_number",
  187. "fn_suffix",
  188. "day_of_origin",
  189. "ac_owner",
  190. "ac_subtype",
  191. "ac_version",
  192. "ac_registration",
  193. "dep_ap_actual",
  194. "dep_ap_sched",
  195. "dep_dt_est",
  196. "dep_sched_dt",
  197. "arr_ap_actual",
  198. "arr_ap_sched",
  199. "arr_dt_est",
  200. "arr_sched_dt",
  201. "slot_time_actual",
  202. "leg_type",
  203. "status",
  204. "employer_cockpit",
  205. "employer_cabin",
  206. "cycles",
  207. "delay_code_01",
  208. "delay_code_02",
  209. "delay_code_03",
  210. "delay_code_04",
  211. "delay_time_01",
  212. "delay_time_02",
  213. "delay_time_03",
  214. "delay_time_04",
  215. "subdelay_code_01",
  216. "subdelay_code_02",
  217. "subdelay_code_03",
  218. "subdelay_code_04",
  219. "pax_booked_c",
  220. "pax_booked_y",
  221. "pax_booked_trs_c",
  222. "pax_booked_trs_y",
  223. "pad_booked_c",
  224. "pad_booked_y",
  225. "offblock_dt_a",
  226. "airborne_dt_a",
  227. "landing_dt_a",
  228. "onblock_dt_a",
  229. "offblock_dt_f",
  230. "airborne_dt_f",
  231. "landing_dt_f",
  232. "onblock_dt_f",
  233. "offblock_dt_m",
  234. "airborne_dt_m",
  235. "landing_dt_m",
  236. "onblock_dt_m",
  237. "eet",
  238. ],
  239. "exportPGRGPN.txt": [
  240. "date",
  241. "tlc",
  242. "actype",
  243. "al",
  244. "fnum",
  245. "ddep",
  246. "hdep",
  247. "ddes",
  248. "hdes",
  249. "dep",
  250. "des",
  251. "label",
  252. "type",
  253. ],
  254. "ExportPGRGPNmois.txt": [
  255. "date",
  256. "tlc",
  257. "actype",
  258. "al",
  259. "fnum",
  260. "ddep",
  261. "hdep",
  262. "ddes",
  263. "hdes",
  264. "dep",
  265. "des",
  266. "label",
  267. "type",
  268. ],
  269. "exportlicence.txt": [
  270. "tlc",
  271. "lname",
  272. "mname",
  273. "fname",
  274. "expire",
  275. "ac",
  276. "college",
  277. "base",
  278. ],
  279. };
  280. final Map<String, String> scopes = {
  281. "secondprgtype.txt": "day_of_origin",
  282. "exportPGRGPN.txt": "date",
  283. "ExportPGRGPNmois.txt": "date",
  284. "exportlicence.txt": "tlc",
  285. };
  286. final Map<String, String> logTables = {
  287. "secondprgtype.txt": "aclegs_csv_log",
  288. "ExportPGRGPNmois.txt": "pnlegs_csv_log",
  289. "exportPGRGPN.txt": "pnlegs_csv_log",
  290. "exportlicence.txt": "licences_csv_log",
  291. };
  292. //all tables trackers: key,add,remove
  293. final Map<String, List<Map<String, dynamic>>> trackers = {
  294. "secondprgtype.txt": [
  295. {
  296. "table": "aclegs_log_reg",
  297. "groupby": [
  298. "day_of_origin",
  299. "dep_sched_dt",
  300. "fn_carrier",
  301. "fn_number",
  302. "dep_ap_sched",
  303. "arr_ap_sched",
  304. // "dep_ap_actual",
  305. // "arr_ap_actual"
  306. ],
  307. "track": [
  308. "ac_registration",
  309. ]
  310. },
  311. {
  312. "table": "aclegs_log_leg",
  313. "groupby": [
  314. "day_of_origin",
  315. "dep_sched_dt",
  316. "fn_carrier",
  317. "fn_number",
  318. "dep_ap_sched",
  319. "arr_ap_sched",
  320. ],
  321. "track": [
  322. "dep_ap_actual",
  323. "arr_ap_actual",
  324. ]
  325. }
  326. ],
  327. "exportPGRGPN.txt": [
  328. {
  329. "table": "pnlegs_log_roster",
  330. "groupby": ["date", "tlc"],
  331. "track": ["dep", "des", "al", "fnum", "label"]
  332. },
  333. {
  334. "table": "pnlegs_log_duty",
  335. "groupby": ["date", "dep", "des", "al", "fnum", "label"],
  336. "track": ["tlc"]
  337. },
  338. {
  339. "table": "pnlegs_log_sched",
  340. "groupby": ["date", "dep", "des", "al", "fnum", "label"],
  341. "track": ["hdep", "hdes"]
  342. },
  343. ],
  344. "ExportPGRGPNmois.txt": [
  345. {
  346. "table": "pnlegs_log_roster",
  347. "groupby": ["date", "tlc"],
  348. "track": ["dep", "des", "al", "fnum", "label"]
  349. },
  350. {
  351. "table": "pnlegs_log_duty",
  352. "groupby": ["date", "dep", "des", "al", "fnum", "label"],
  353. "track": ["tlc"]
  354. },
  355. {
  356. "table": "pnlegs_log_sched",
  357. "groupby": ["date", "dep", "des", "al", "fnum", "label"],
  358. "track": ["hdep", "hdes"]
  359. },
  360. ],
  361. "exportlicence.txt": [
  362. {
  363. "table": "licences_log_qualif",
  364. "groupby": [
  365. "tlc",
  366. "fname",
  367. "mname",
  368. "lname",
  369. ],
  370. "track": [
  371. "ac",
  372. "college",
  373. "base",
  374. ]
  375. }
  376. ],
  377. };
  378. Future<List<Map<String, dynamic>>> parseCsv() async {
  379. final headers = _headers[filename] ?? [];
  380. if (headers.isEmpty) {
  381. throw Exception('No headers found for file: $filename');
  382. }
  383. // Initialize an empty list to hold the parsed data
  384. List<Map<String, dynamic>> data = [];
  385. // Read the CSV file
  386. final file = File(filepath);
  387. final lines = await file.readAsLines();
  388. // Iterate over each line in the CSV file
  389. for (int i = 0; i < lines.length; i++) {
  390. // Split the line into individual values
  391. final values = lines[i].split(',');
  392. if (values.length != headers.length) {
  393. //print('Skipping line $i: Incorrect number of values: line: $i');
  394. continue;
  395. }
  396. // Create a map for the current row
  397. Map<String, dynamic> row = {};
  398. // Assign each value to the corresponding header
  399. for (int j = 0; j < headers.length; j++) {
  400. row[headers[j]] = values[j].trim().removeQuotes.trim().nullIfEmpty;
  401. }
  402. // Add the row map to the data list
  403. data.add(row);
  404. }
  405. // Return the parsed data
  406. return data;
  407. }
  408. List<String> get filesTomonitor => _headers.keys.toList();
  409. Future<void> go({bool donttouchdb = false}) async {
  410. if (!filesTomonitor.contains(filename)) return;
  411. final allmapsToInsert = await parseCsv();
  412. final scopeName = scopes[filename] ?? "";
  413. final scopesInNew = allmapsToInsert
  414. .fold(<String>{}, (t, e) => t..add(e[scopeName] ?? "")).toList()
  415. ..sort((a, b) => _parseDate(a).compareTo(_parseDate(b)));
  416. //special export prgpn list of tlcs imported in new file
  417. Set<String> tlcs = {};
  418. for (var scopeInNew in scopesInNew) {
  419. final mapsToInsert =
  420. allmapsToInsert.where((e) => e[scopeName] == scopeInNew).toList();
  421. List<Map<String, dynamic>> oldIds = [];
  422. List<Map<String, dynamic>> oldComparable = [];
  423. //load old data
  424. final res = await supabase
  425. .from(tables[filename]!)
  426. .select()
  427. .eq(scopeName, scopeInNew)
  428. .limit(300000);
  429. oldIds.addAll(res.map((e) => {"id": e["id"]}));
  430. oldComparable.addAll(res.map((e) => e..remove("id")));
  431. final comparisonResult = compareLists(oldComparable, mapsToInsert);
  432. List<int> indexToRemove = comparisonResult.removeIndices;
  433. List<int> indexToMaintain = comparisonResult.maintainIndices;
  434. final dataToInsert = comparisonResult.insertData;
  435. //special export prgpn
  436. List<int> indexRemovedToMaintain = [];
  437. if (filename == "exportPGRGPN.txt" ||
  438. filename == "ExportPGRGPNmois.txt") {
  439. //if there is line insert with tlc and date
  440. for (final data in mapsToInsert) {
  441. tlcs..add(data["tlc"] ?? "");
  442. }
  443. indexRemovedToMaintain = indexToRemove
  444. .where((e) => !tlcs.contains(oldComparable[e]["tlc"]))
  445. .toList();
  446. // indexToMaintain.addAll(indexRemovedToMaintain);
  447. // indexToRemove.removeWhere((e) => indexRemovedToMaintain.contains(e));
  448. for (var e in indexRemovedToMaintain) {
  449. indexToMaintain.add(e);
  450. indexToRemove.remove(e);
  451. }
  452. }
  453. try {
  454. if (!donttouchdb)
  455. for (var e in chunkList(
  456. indexToRemove.map((f) => oldIds[f]['id']).toList(), 100)) {
  457. await supabase
  458. .from(tables[filename]!) // Replace with your actual table name
  459. .delete()
  460. .inFilter('id', e);
  461. }
  462. // insering new data
  463. if (!donttouchdb)
  464. await supabase
  465. .from(tables[filename]!) // Replace with your actual table name
  466. .insert(dataToInsert);
  467. } catch (e, stackTrace) {
  468. print('Error: $e\n$stackTrace');
  469. }
  470. print(
  471. " Scope:$scopeInNew insert:${dataToInsert.length} remove:${indexToRemove.length} maintain:${indexToMaintain.length}");
  472. //logging changes into tables
  473. final logTable = logTables[filename]!;
  474. final logData = dataToInsert
  475. .map((e) => {"scope": scopeInNew, "data": e, "action": "insert"})
  476. .toList();
  477. for (var e in chunkList(
  478. indexToRemove.map((f) => oldComparable[f]).toList(), 100)) {
  479. // e.forEach((k) => print("log: -: $k"));
  480. if (!donttouchdb)
  481. await supabase
  482. .from(logTable) // Replace with your actual table name
  483. .insert(e
  484. .map((e) =>
  485. {"scope": scopeInNew, "data": e, "action": "delete"})
  486. .toList());
  487. }
  488. for (var e in chunkList(logData, 100)) {
  489. // e.forEach((k) => print("log: +: $k"));
  490. if (!donttouchdb) await supabase.from(logTable).insert(e);
  491. }
  492. //logging tracking data
  493. final List<Map<String, dynamic>> mapsToInsertNoDelete =
  494. List.from(mapsToInsert)
  495. ..addAll(indexRemovedToMaintain.map((e) => oldComparable[e]));
  496. for (var tracker in trackers[filename] ?? []) {
  497. final String table = tracker["table"];
  498. final List<String> groupby = tracker["groupby"] ?? [];
  499. final List<String> track = tracker["track"] ?? [];
  500. final stateOld = oldComparable.groupBy(
  501. (e) => groupby.map((f) => e[f]).join("|"),
  502. dataFunction: (e) =>
  503. e.filterKeys(track).values.map((j) => j ?? "").join("|"));
  504. final stateNew = mapsToInsertNoDelete.groupBy(
  505. (e) => groupby.map((f) => e[f]).join("|"),
  506. dataFunction: (e) =>
  507. e.filterKeys(track).values.map((j) => j ?? "").join("|"));
  508. List logs = [];
  509. for (var key
  510. in (stateOld.keys.toList()..addAll(stateNew.keys)).toSet()) {
  511. final (add, remove) =
  512. (stateNew[key] ?? []).compareWith(stateOld[key] ?? []);
  513. //if (!key.endsWith(tracktlc)) continue;
  514. //foreach add remove
  515. if (add.isNotEmpty || remove.isNotEmpty) {
  516. final row = {
  517. "key": list2map(groupby, key.split("|")),
  518. "add": add.isNotEmpty
  519. ? add.map((e) => list2map(track, e.split("|"))).toList()
  520. : [],
  521. "remove": remove.isNotEmpty
  522. ? remove.map((e) => list2map(track, e.split("|"))).toList()
  523. : [],
  524. };
  525. logs.add(row);
  526. // if (key.contains("7506")) {
  527. // print(
  528. // " table:$table key: $key \n\n ---old:${stateOld[key]} \n\n +++new:${stateNew[key]}");
  529. // }
  530. }
  531. }
  532. //print(" Tracker:$table");
  533. for (var e in chunkList(logs, 100)) {
  534. // e.forEach((k) => print("log: +: $k"));
  535. if (!donttouchdb) await supabase.from(table).insert(e);
  536. }
  537. }
  538. }
  539. }
  540. Map<String, dynamic> list2map(List<String> keys, List<dynamic> data) {
  541. Map<String, dynamic> map = {};
  542. for (var i = 0; i < keys.length; i++) {
  543. final key = keys[i];
  544. final datum = data[i];
  545. map[key] = datum;
  546. }
  547. return map;
  548. }
  549. // Compare two lists of maps and return the indices to maintain, remove, and insert
  550. ({
  551. List<int> maintainIndices,
  552. List<int> removeIndices,
  553. // List<int> insertIndices
  554. List<Map> insertData
  555. }) compareLists(
  556. List<Map<String, dynamic>> map1, List<Map<String, dynamic>> map2) {
  557. List<int> maintainIndices = [];
  558. List<int> removeIndices = [];
  559. List<Map<String, dynamic>> insertData = List.from(map2);
  560. // Find indices to maintain and remove in map1
  561. for (int i = 0; i < map1.length; i++) {
  562. final pos = insertData.findMap(map1[i]);
  563. if (pos > -1) {
  564. maintainIndices.add(i); // Item exists in both lists
  565. insertData.removeAt(pos);
  566. } else {
  567. removeIndices.add(i); // Item does not exist in map2
  568. }
  569. }
  570. return (
  571. maintainIndices: maintainIndices,
  572. removeIndices: removeIndices,
  573. insertData: insertData
  574. );
  575. }
  576. List<List<T>> chunkList<T>(List<T> list, int chunkSize) {
  577. if (chunkSize <= 0) {
  578. throw ArgumentError('chunkSize must be greater than 0');
  579. }
  580. List<List<T>> chunks = [];
  581. for (var i = 0; i < list.length; i += chunkSize) {
  582. chunks.add(list.sublist(
  583. i, i + chunkSize > list.length ? list.length : i + chunkSize));
  584. }
  585. return chunks;
  586. }
  587. }
  588. dynamic _parseDate(String date) {
  589. final parts = date.split('/');
  590. if (parts.length == 3) {
  591. return DateTime(
  592. int.parse(parts[2]), // year
  593. int.parse(parts[1]), // month
  594. int.parse(parts[0]), // day
  595. );
  596. } else {
  597. return date;
  598. }
  599. }
  600. extension CompareIterables<T> on Iterable<T> {
  601. /// Compares this iterable with another iterable and returns a map containing:
  602. /// - 'added': Items that are in the other iterable but not in this one.
  603. /// - 'removed': Items that are in this iterable but not in the other one.
  604. (Iterable<T> add, Iterable<T> remove) compareWith(Iterable<T> other) {
  605. final Set<T> thisSet = this.toSet();
  606. final Set<T> otherSet = other.toSet();
  607. final Set<T> removed = otherSet.difference(thisSet);
  608. final Set<T> added = thisSet.difference(otherSet);
  609. return (added, removed);
  610. }
  611. }
  612. extension FilterMapByKeys<K, V> on Map<K, V> {
  613. Map<K, V?> filterKeys(List<K> keysToKeep) {
  614. final filteredMap = <K, V?>{};
  615. for (final key in keysToKeep) {
  616. if (this.containsKey(key)) {
  617. filteredMap[key] = this[key];
  618. }
  619. }
  620. return filteredMap;
  621. }
  622. }
  623. extension RemoveNull<T> on Iterable<T?> {
  624. /// Returns a new iterable with all null values removed.
  625. Iterable<T> removeNull() {
  626. return where((element) => element != null).cast<T>();
  627. }
  628. }
  629. extension GroupBy<T> on Iterable<T> {
  630. Map<K, List> groupBy<K>(K Function(T) keyFunction,
  631. {Function(T)? dataFunction, bool Function(T)? keyIsNullFunction}) {
  632. final map = <K, List>{};
  633. for (final element in this) {
  634. final key = keyFunction(element);
  635. final keyIsNull =
  636. keyIsNullFunction == null ? false : keyIsNullFunction(element);
  637. if (keyIsNull || key == null) continue;
  638. if (dataFunction != null) {
  639. map.putIfAbsent(key, () => []).add(dataFunction(element));
  640. } else
  641. map.putIfAbsent(key, () => []).add(element);
  642. }
  643. return map;
  644. }
  645. }
  646. extension NullIfEmpty on String {
  647. String? get nullIfEmpty => isEmpty ? null : this;
  648. }
  649. extension RemoveQuotes on String {
  650. String get removeQuotes {
  651. if (isEmpty) return this; // Return if the string is empty
  652. // Remove the first and last characters if they are quotes
  653. String result = this;
  654. // Check if the first character is a quote
  655. bool startsWithQuote = result.startsWith('"') || result.startsWith("'");
  656. if (startsWithQuote) result = result.substring(1);
  657. // Check if the last character is a quote
  658. bool endsWithQuote = result.endsWith('"') || result.endsWith("'");
  659. if (endsWithQuote) result = result.substring(0, result.length - 1);
  660. return result;
  661. }
  662. }
  663. bool mapsAreEqual(Map<String, dynamic> map1, Map<String, dynamic> map2) {
  664. if (map1.length != map2.length) return false;
  665. for (var key in map1.keys) {
  666. if (map1[key] != map2[key]) return false;
  667. }
  668. return true;
  669. }
  670. extension ContainsMap on List<Map<String, dynamic>> {
  671. bool containsMap(Map<String, dynamic> map) {
  672. for (var item in this) {
  673. if (mapsAreEqual(item, map)) return true;
  674. }
  675. return false;
  676. }
  677. int findMap(Map<String, dynamic> map) {
  678. for (int i = 0; i < this.length; i++) {
  679. if (mapsAreEqual(this.elementAt(i), map)) return i;
  680. }
  681. return -1;
  682. }
  683. }