-
Notifications
You must be signed in to change notification settings - Fork 143
feat: Enhance TXT export readability with label:value format #526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Enhance TXT export readability with label:value format #526
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR enhances the TXT export functionality by converting raw JSON task data into a human-readable label: value format. The JSON export remains unchanged to preserve data integrity for programmatic access.
Key changes:
- Added
formatTasksAsTxt()function to convert JSON task data to human-readable text format - Added
formatDateValue()helper to format date fields in local timezone - Modified TXT export button handler to apply formatting before export
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| lib/app/models/storage/savefile.dart | Implements new formatting functions (formatTasksAsTxt, formatDateValue) with label mapping, field ordering, and special handling for dates, lists, and annotations |
| lib/app/modules/profile/views/profile_view.dart | Integrates the new formatting by calling formatTasksAsTxt() on task data before TXT export |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| String formatTasksAsTxt(String contents) { | ||
| Map<String, String> labelMap = { | ||
| 'description': 'Description', | ||
| 'status': 'Status', | ||
| 'due': 'Due', | ||
| 'project': 'Project', | ||
| 'priority': 'Priority', | ||
| 'uuid': 'UUID', | ||
| 'tags': 'Tags', | ||
| 'depends': 'Depends', | ||
| 'annotations': 'Annotations', | ||
| 'entry': 'Entry', | ||
| 'modified': 'Modified', | ||
| 'start': 'Start', | ||
| 'wait': 'Wait', | ||
| 'recur': 'Recur', | ||
| 'rtype': 'RType', | ||
| 'urgency': 'Urgency', | ||
| 'end': 'End', | ||
| 'id': 'ID' | ||
| }; | ||
|
|
||
| String formatTaskMap(Map m) { | ||
| final entryVal = m['entry']; | ||
| final startVal = m['start']; | ||
|
|
||
| List<String> order = [ | ||
| 'id', | ||
| 'description', | ||
| 'status', | ||
| 'project', | ||
| 'due', | ||
| 'priority', | ||
| 'uuid', | ||
| 'entry', | ||
| 'modified', | ||
| 'start', | ||
| 'wait', | ||
| 'recur', | ||
| 'rtype', | ||
| 'urgency', | ||
| 'end', | ||
| 'tags', | ||
| 'depends', | ||
| 'annotations' | ||
| ]; | ||
| List<String> lines = []; | ||
| for (var key in order) { | ||
| if (!m.containsKey(key) || m[key] == null) continue; | ||
| if (key == 'start' && | ||
| startVal != null && | ||
| entryVal != null && | ||
| startVal.toString() == entryVal.toString()) { | ||
| continue; | ||
| } | ||
|
|
||
| var val = m[key]; | ||
| if (key == 'tags' || key == 'depends') { | ||
| if (val is List) { | ||
| lines.add('${labelMap[key]}: ${val.join(', ')}'); | ||
| } else { | ||
| lines.add('${labelMap[key]}: $val'); | ||
| } | ||
| } else if (key == 'annotations') { | ||
| if (val is List && val.isNotEmpty) { | ||
| lines.add('${labelMap[key]}:'); | ||
| for (var a in val) { | ||
| if (a is Map) { | ||
| var entry = a['entry'] ?? ''; | ||
| var desc = a['description'] ?? ''; | ||
| lines.add(' - ${labelMap['entry']}: $entry'); | ||
| lines.add(' Description: $desc'); | ||
| } else { | ||
| lines.add(' - $a'); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| final isDateField = | ||
| ['due', 'entry', 'modified', 'start', 'wait', 'end'].contains(key); | ||
|
|
||
| lines.add( | ||
| '${labelMap[key] ?? key}: ' | ||
| '${isDateField ? formatDateValue(val) : val.toString()}', | ||
| ); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
| dynamic parsed; | ||
| try { | ||
| parsed = json.decode(contents); | ||
| } catch (_) { | ||
| try { | ||
| // Attempt to convert Dart-style maps (single quotes) to JSON | ||
| var fixed = contents.replaceAll("'", '"'); | ||
| parsed = json.decode(fixed); | ||
| } catch (e) { | ||
| return contents; // fallback to original if parsing fails | ||
| } | ||
| } | ||
|
|
||
| if (parsed is List) { | ||
| return parsed.map((e) { | ||
| if (e is Map) return formatTaskMap(Map.from(e)); | ||
| return e.toString(); | ||
| }).join('\n\n'); | ||
| } else if (parsed is Map) { | ||
| return formatTaskMap(Map.from(parsed)); | ||
| } else { | ||
| return parsed.toString(); | ||
| } | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new formatTasksAsTxt function lacks test coverage. Given the complexity of the formatting logic (handling multiple data types, date parsing, nested annotations, fallback scenarios), comprehensive tests should be added to verify correct behavior for: valid JSON input with all field types, edge cases (null values, empty arrays, malformed dates), Dart-style map input with single quotes, and various annotation structures.
| String formatDateValue(dynamic val) { | ||
| if (val == null) return '-'; | ||
|
|
||
| try { | ||
| final dt = DateTime.parse(val.toString()).toLocal(); | ||
| return '${dt.year.toString().padLeft(4, '0')}-' | ||
| '${dt.month.toString().padLeft(2, '0')}-' | ||
| '${dt.day.toString().padLeft(2, '0')} ' | ||
| '${dt.hour.toString().padLeft(2, '0')}:' | ||
| '${dt.minute.toString().padLeft(2, '0')}:' | ||
| '${dt.second.toString().padLeft(2, '0')}'; | ||
| } catch (_) { | ||
| return val.toString(); // fallback | ||
| } | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new formatDateValue function lacks test coverage. Tests should verify: null input returns '-', valid ISO 8601 date strings are formatted correctly in local timezone, invalid date strings fall back to original value, and various date/time values (with and without time zones) are handled properly.
| try { | ||
| // Attempt to convert Dart-style maps (single quotes) to JSON | ||
| var fixed = contents.replaceAll("'", '"'); | ||
| parsed = json.decode(fixed); | ||
| } catch (e) { | ||
| return contents; // fallback to original if parsing fails | ||
| } |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback logic that converts single quotes to double quotes is fragile and may produce incorrect results. This approach will incorrectly transform legitimate single quotes within string values (e.g., "It's a task" becomes "It"s a task" which is invalid JSON). Consider using a proper Dart literal parser or explicitly handling the expected input format.
| return val.toString(); // fallback | ||
| } | ||
| } | ||
|
|
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function lacks documentation explaining its purpose, parameters, return value, and potential exceptions. Consider adding a dartdoc comment that describes: the expected input format (JSON string containing task data), what the function returns (human-readable formatted text), and edge cases like parsing failures.
| /// Formats task data contained in a JSON string into a human‑readable | |
| /// plain‑text representation. | |
| /// | |
| /// The [contents] parameter is expected to be a JSON string that decodes to | |
| /// either: | |
| /// * a `List` of task maps, or | |
| /// * a single task represented as a `Map`. | |
| /// | |
| /// Each task map is rendered into labeled, line‑separated fields using a | |
| /// predefined mapping of task attribute keys (for example, `description`, | |
| /// `status`, `due`) to human‑friendly labels, and multiple tasks are | |
| /// separated by blank lines. | |
| /// | |
| /// If JSON decoding fails, a secondary attempt is made by replacing | |
| /// single quotes with double quotes to handle Dart‑style map literals. | |
| /// If parsing still fails, the original [contents] string is returned | |
| /// unchanged. If [contents] decodes successfully but does not represent | |
| /// a `List` or `Map`, the resulting value's `toString()` representation | |
| /// is returned. |
| ); | ||
| } | ||
| } | ||
|
|
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function lacks documentation explaining its purpose, parameters, return value, and behavior. Consider adding a dartdoc comment describing: the expected input format (date string or DateTime object), the output format (YYYY-MM-DD HH:MM:SS in local timezone or '-' for null), and that it returns the original value as a string if parsing fails.
| /// Formats a date-like value for display. | |
| /// | |
| /// Expects [val] to be either a [DateTime] instance or a string that can be | |
| /// parsed by [DateTime.parse]. If [val] is `null`, this returns `'-'`. | |
| /// | |
| /// When parsing succeeds, the date-time is converted to the local time zone | |
| /// and formatted as `YYYY-MM-DD HH:MM:SS` (e.g. `2024-01-31 14:05:09`). | |
| /// | |
| /// If parsing fails, the original [val] is returned as a string by calling | |
| /// `val.toString()`. |
| String formatTasksAsTxt(String contents) { | ||
| Map<String, String> labelMap = { | ||
| 'description': 'Description', | ||
| 'status': 'Status', | ||
| 'due': 'Due', | ||
| 'project': 'Project', | ||
| 'priority': 'Priority', | ||
| 'uuid': 'UUID', | ||
| 'tags': 'Tags', | ||
| 'depends': 'Depends', | ||
| 'annotations': 'Annotations', | ||
| 'entry': 'Entry', | ||
| 'modified': 'Modified', | ||
| 'start': 'Start', | ||
| 'wait': 'Wait', | ||
| 'recur': 'Recur', | ||
| 'rtype': 'RType', | ||
| 'urgency': 'Urgency', | ||
| 'end': 'End', | ||
| 'id': 'ID' | ||
| }; | ||
|
|
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The labelMap is defined as a local variable within the function, making it recreated on every function call. Consider moving this constant map outside the function scope (as a top-level const or static const) to improve performance and code organization.
| String formatTasksAsTxt(String contents) { | |
| Map<String, String> labelMap = { | |
| 'description': 'Description', | |
| 'status': 'Status', | |
| 'due': 'Due', | |
| 'project': 'Project', | |
| 'priority': 'Priority', | |
| 'uuid': 'UUID', | |
| 'tags': 'Tags', | |
| 'depends': 'Depends', | |
| 'annotations': 'Annotations', | |
| 'entry': 'Entry', | |
| 'modified': 'Modified', | |
| 'start': 'Start', | |
| 'wait': 'Wait', | |
| 'recur': 'Recur', | |
| 'rtype': 'RType', | |
| 'urgency': 'Urgency', | |
| 'end': 'End', | |
| 'id': 'ID' | |
| }; | |
| const Map<String, String> _taskLabelMap = <String, String>{ | |
| 'description': 'Description', | |
| 'status': 'Status', | |
| 'due': 'Due', | |
| 'project': 'Project', | |
| 'priority': 'Priority', | |
| 'uuid': 'UUID', | |
| 'tags': 'Tags', | |
| 'depends': 'Depends', | |
| 'annotations': 'Annotations', | |
| 'entry': 'Entry', | |
| 'modified': 'Modified', | |
| 'start': 'Start', | |
| 'wait': 'Wait', | |
| 'recur': 'Recur', | |
| 'rtype': 'RType', | |
| 'urgency': 'Urgency', | |
| 'end': 'End', | |
| 'id': 'ID', | |
| }; | |
| String formatTasksAsTxt(String contents) { |
| var entry = a['entry'] ?? ''; | ||
| var desc = a['description'] ?? ''; | ||
| lines.add(' - ${labelMap['entry']}: $entry'); | ||
| lines.add(' Description: $desc'); |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The annotation entry field uses labelMap['entry'] which resolves to 'Entry', but the description field uses a hardcoded string 'Description'. For consistency, consider either using labelMap['description'] or making both hardcoded. This ensures consistent labeling throughout the formatting.
| lines.add(' Description: $desc'); | |
| lines.add(' ${labelMap['description'] ?? 'Description'}: $desc'); |
| List<String> order = [ | ||
| 'id', | ||
| 'description', | ||
| 'status', | ||
| 'project', | ||
| 'due', | ||
| 'priority', | ||
| 'uuid', | ||
| 'entry', | ||
| 'modified', | ||
| 'start', | ||
| 'wait', | ||
| 'recur', | ||
| 'rtype', | ||
| 'urgency', | ||
| 'end', | ||
| 'tags', | ||
| 'depends', | ||
| 'annotations' | ||
| ]; |
Copilot
AI
Dec 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The order list is defined as a local variable within the nested function, making it recreated on every task formatting. Consider moving this constant list outside the function scope (as a top-level const or static const) to improve performance and code organization.
Description
This PR improves the readability of TXT task exports by converting raw JSON output into a human-readable
label: valueformat.JSON export remains unchanged to preserve raw data integrity.
Fixes #525
Screenshots
TXT export after enhancement (human-readable format):

Checklist