diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 12d41db3..efcbc400 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,12 +1,8 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; -import 'package:querya_desktop/core/csv/result_grid_csv.dart'; -import 'package:querya_desktop/core/csv/save_result_grid_csv.dart'; -import 'package:querya_desktop/core/json/result_grid_json.dart'; -import 'package:querya_desktop/core/json/save_result_grid_json.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. @@ -84,67 +80,67 @@ class ResultsTab extends StatelessWidget { size: ButtonSize.small, onPressed: () { unawaited(() async { - final outcome = await saveResultGridCsvFile( + await DataExportService.copyToClipboard( + DataExportFormat.csv, columns: columns, rows: rows, ); - if (!context.mounted) return; - if (outcome == SaveResultGridCsvOutcome.error) { - await _showSaveFileErrorDialog(context); - } }()); }, leading: const material.Icon( - material.Icons.save_alt_rounded, + material.Icons.copy_rounded, size: 14, ), - child: const Text('Save as CSV…'), + child: const Text('Copy as CSV'), ), OutlineButton( size: ButtonSize.small, onPressed: () { unawaited(() async { - final outcome = await saveResultGridJsonFile( + await DataExportService.copyToClipboard( + DataExportFormat.json, columns: columns, rows: rows, ); - if (!context.mounted) return; - if (outcome == SaveResultGridJsonOutcome.error) { - await _showSaveFileErrorDialog(context); - } }()); }, leading: const material.Icon( - material.Icons.data_object_rounded, + material.Icons.copy_rounded, size: 14, ), - child: const Text('Save as JSON…'), + child: const Text('Copy as JSON'), ), - OutlineButton( - size: ButtonSize.small, - onPressed: () { + _ExportMenuButton( + label: 'Copy formatted ▾', + icon: material.Icons.copy_all_rounded, + isSave: false, + onSelected: (format) { unawaited(() async { - final csv = await resultGridAsCsvAsync(columns, rows); - await Clipboard.setData(ClipboardData(text: csv)); + await DataExportService.copyToClipboard( + format, + columns: columns, + rows: rows, + ); }()); }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, - ), - child: const Text('Copy as CSV'), ), - OutlineButton( - size: ButtonSize.small, - onPressed: () { - final json = resultGridAsJson(columns, rows); - Clipboard.setData(ClipboardData(text: json)); + _ExportMenuButton( + label: 'Save to file ▾', + icon: material.Icons.save_alt_rounded, + isSave: true, + onSelected: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: columns, + rows: rows, + ); + if (!context.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, - ), - child: const Text('Copy as JSON'), ), ], ), @@ -175,3 +171,88 @@ Future _showSaveFileErrorDialog(material.BuildContext context) { ), ); } + +class _ExportMenuButton extends StatelessWidget { + const _ExportMenuButton({ + required this.label, + required this.icon, + required this.onSelected, + required this.isSave, + }); + + final String label; + final material.IconData icon; + final material.ValueChanged onSelected; + final bool isSave; + + @override + Widget build(BuildContext context) { + return material.PopupMenuButton( + tooltip: label, + onSelected: onSelected, + itemBuilder: (context) => [ + material.PopupMenuItem( + value: DataExportFormat.csv, + child: material.Row( + children: [ + const material.Icon( + material.Icons.table_chart_outlined, size: 16), + const material.SizedBox(width: 8), + material.Text(isSave ? 'CSV (.csv)' : 'Copy as CSV'), + ], + ), + ), + material.PopupMenuItem( + value: DataExportFormat.json, + child: material.Row( + children: [ + const material.Icon( + material.Icons.data_object_rounded, size: 16), + const material.SizedBox(width: 8), + material.Text(isSave ? 'JSON (.json)' : 'Copy as JSON'), + ], + ), + ), + material.PopupMenuItem( + value: DataExportFormat.markdown, + child: material.Row( + children: [ + const material.Icon(material.Icons.code_rounded, size: 16), + const material.SizedBox(width: 8), + material.Text(isSave ? 'Markdown Table (.md)' : 'Copy as Markdown Table'), + ], + ), + ), + material.PopupMenuItem( + value: DataExportFormat.sqlDump, + child: material.Row( + children: [ + const material.Icon(material.Icons.storage_rounded, size: 16), + const material.SizedBox(width: 8), + material.Text(isSave ? 'SQL INSERT Dump (.sql)' : 'Copy as SQL Dump'), + ], + ), + ), + ], + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + border: material.Border.all( + color: Theme.of(context).colorScheme.border, + ), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(icon, + size: 14, color: Theme.of(context).colorScheme.foreground), + const material.SizedBox(width: 6), + Text(label).small(), + ], + ), + ), + ); + } +} diff --git a/lib/shared/services/data_export_service.dart b/lib/shared/services/data_export_service.dart new file mode 100644 index 00000000..7a60bb66 --- /dev/null +++ b/lib/shared/services/data_export_service.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/csv/result_grid_csv.dart'; + +enum DataExportFormat { + csv, + json, + markdown, + sqlDump, +} + +enum SaveExportOutcome { + cancelled, + written, + error, +} + +/// Universal service for formatting, copying, and saving query result data. +class DataExportService { + DataExportService._(); + + /// Formats rows as CSV string using `resultGridAsCsv`. + static String formatCsv(List columns, List> rows) { + return resultGridAsCsv(columns, rows); + } + + /// Formats rows as a JSON array of objects `[{"col": "val", ...}]`. + static String formatJson( + List columns, + List> rows, { + bool asObjectArray = true, + }) { + if (!asObjectArray) { + final normalizedRows = rows + .map( + (r) => List.generate( + columns.length, + (i) => i < r.length ? r[i] : '', + growable: false, + ), + ) + .toList(growable: false); + return const JsonEncoder.withIndent(' ').convert({ + 'columns': columns, + 'rows': normalizedRows, + }); + } + + final list = >[]; + for (final row in rows) { + final obj = {}; + for (var i = 0; i < columns.length; i++) { + final colName = columns[i]; + final val = i < row.length ? row[i] : null; + if (val == 'NULL' || val == null) { + obj[colName] = null; + } else { + obj[colName] = val; + } + } + list.add(obj); + } + return const JsonEncoder.withIndent(' ').convert(list); + } + + /// Formats rows as a GitHub Flavored Markdown table. + static String formatMarkdownTable( + List columns, + List> rows, + ) { + if (columns.isEmpty) return ''; + final buf = StringBuffer(); + + String escapeMd(String s) { + if (s == 'NULL') return '`NULL`'; + return s + .replaceAll('|', '\\|') + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' '); + } + + buf.write('| ${columns.map(escapeMd).join(' | ')} |\n'); + buf.write('| ${columns.map((_) => '---').join(' | ')} |\n'); + + for (final row in rows) { + final padded = List.generate( + columns.length, + (i) => i < row.length ? escapeMd(row[i]) : '`NULL`', + growable: false, + ); + buf.write('| ${padded.join(' | ')} |\n'); + } + return buf.toString(); + } + + /// Formats rows as SQL INSERT statements (`INSERT INTO table (...) VALUES (...);`). + static String formatSqlInsertDump( + String tableName, + List columns, + List> rows, + ) { + if (columns.isEmpty || rows.isEmpty) return ''; + final safeTable = + tableName.trim().isEmpty ? 'export_table' : tableName.trim(); + final colList = columns + .map((c) => c.contains(' ') || c.contains('-') ? '"$c"' : c) + .join(', '); + + final buf = StringBuffer(); + for (final row in rows) { + buf.write('INSERT INTO $safeTable ($colList) VALUES ('); + for (var i = 0; i < columns.length; i++) { + if (i > 0) buf.write(', '); + if (i >= row.length || row[i] == 'NULL') { + buf.write('NULL'); + } else { + final val = row[i]; + final escaped = val.replaceAll("'", "''"); + buf.write("'$escaped'"); + } + } + buf.write(');\n'); + } + return buf.toString(); + } + + /// Copies data in [format] to the system clipboard. + static Future copyToClipboard( + DataExportFormat format, { + required List columns, + required List> rows, + String tableName = 'export_table', + }) async { + final text = await formatAsync( + format, + columns: columns, + rows: rows, + tableName: tableName, + ); + await Clipboard.setData(ClipboardData(text: text)); + } + + /// Formats data on a background isolate to prevent UI stutter on large datasets. + static Future formatAsync( + DataExportFormat format, { + required List columns, + required List> rows, + String tableName = 'export_table', + }) { + return Isolate.run(() { + switch (format) { + case DataExportFormat.csv: + return formatCsv(columns, rows); + case DataExportFormat.json: + return formatJson(columns, rows); + case DataExportFormat.markdown: + return formatMarkdownTable(columns, rows); + case DataExportFormat.sqlDump: + return formatSqlInsertDump(tableName, columns, rows); + } + }); + } + + /// Opens a native file save dialog and writes the exported text to disk. + static Future saveToFile( + DataExportFormat format, { + required List columns, + required List> rows, + String tableName = 'export_table', + String? suggestedName, + }) async { + final timestamp = + DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first; + String ext; + String label; + switch (format) { + case DataExportFormat.csv: + ext = 'csv'; + label = 'CSV'; + break; + case DataExportFormat.json: + ext = 'json'; + label = 'JSON'; + break; + case DataExportFormat.markdown: + ext = 'md'; + label = 'Markdown'; + break; + case DataExportFormat.sqlDump: + ext = 'sql'; + label = 'SQL Script'; + break; + } + + final name = suggestedName ?? '${tableName}_$timestamp.$ext'; + final location = await getSaveLocation( + acceptedTypeGroups: [ + XTypeGroup(label: label, extensions: [ext]), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) { + return SaveExportOutcome.cancelled; + } + + try { + final content = await formatAsync( + format, + columns: columns, + rows: rows, + tableName: tableName, + ); + await File(path).writeAsString(content); + return SaveExportOutcome.written; + } on Object { + return SaveExportOutcome.error; + } + } +} diff --git a/test/shared/data_export_service_test.dart b/test/shared/data_export_service_test.dart new file mode 100644 index 00000000..e02c4ccc --- /dev/null +++ b/test/shared/data_export_service_test.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; + +void main() { + group('DataExportService', () { + final columns = ['id', 'user_name', 'note']; + final rows = [ + ['101', 'Alice "Queen"', 'First\nline'], + ['102', 'Bob\'s data', 'NULL'], + ]; + + test('formatCsv escapes quotes and newlines accurately', () { + final csv = DataExportService.formatCsv(columns, rows); + expect(csv, contains('id,user_name,note')); + expect(csv, contains('"Alice ""Queen"""')); + expect(csv, contains('"First\nline"')); + }); + + test('formatJson creates object array by default', () { + final jsonStr = DataExportService.formatJson(columns, rows); + final decoded = jsonDecode(jsonStr) as List; + expect(decoded.length, 2); + expect(decoded[0]['id'], '101'); + expect(decoded[0]['user_name'], 'Alice "Queen"'); + expect(decoded[0]['note'], 'First\nline'); + expect(decoded[1]['note'], isNull); // 'NULL' converted to null + }); + + test('formatJson creates table dict when asObjectArray is false', () { + final jsonStr = + DataExportService.formatJson(columns, rows, asObjectArray: false); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded['columns'], ['id', 'user_name', 'note']); + expect((decoded['rows'] as List).length, 2); + }); + + test('formatMarkdownTable escapes pipes and formats header', () { + final md = DataExportService.formatMarkdownTable(columns, rows); + expect(md, contains('| id | user_name | note |')); + expect(md, contains('| --- | --- | --- |')); + expect(md, contains('| 101 | Alice "Queen" | First line |')); + expect(md, contains('Bob\'s data')); + expect(md, contains('`NULL`')); + }); + + test('formatSqlInsertDump escapes single quotes and generates valid SQL', () { + final sql = DataExportService.formatSqlInsertDump('users', columns, rows); + expect( + sql, + contains( + "INSERT INTO users (id, user_name, note) VALUES ('101', 'Alice \"Queen\"', 'First\nline');")); + expect( + sql, + contains( + "INSERT INTO users (id, user_name, note) VALUES ('102', 'Bob''s data', NULL);")); + }); + + test('formatAsync handles formatting off-thread', () async { + final md = await DataExportService.formatAsync( + DataExportFormat.markdown, + columns: columns, + rows: rows, + ); + expect(md, contains('| id | user_name | note |')); + }); + }); +}