Fix submissions using existing tags profile by stripping non-xml-safe chars. Allow customizing changeset comment.

This commit is contained in:
stopflock
2026-02-01 22:22:31 -06:00
parent 659cf5c0f0
commit aba919f8d4
8 changed files with 186 additions and 6 deletions

View File

@@ -41,6 +41,7 @@ import 'state/upload_queue_state.dart';
export 'state/navigation_state.dart' show AppNavigationMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
export 'models/pending_upload.dart' show UploadOperation;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -444,6 +445,7 @@ class AppState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
String? changesetComment,
}) {
_sessionState.updateSession(
directionDeg: directionDeg,
@@ -451,6 +453,7 @@ class AppState extends ChangeNotifier {
operatorProfile: operatorProfile,
target: target,
refinedTags: refinedTags,
changesetComment: changesetComment,
);
// Check tutorial completion if position changed
@@ -466,6 +469,7 @@ class AppState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
String? changesetComment,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
@@ -474,6 +478,7 @@ class AppState extends ChangeNotifier {
target: target,
extractFromWay: extractFromWay,
refinedTags: refinedTags,
changesetComment: changesetComment,
);
// Check tutorial completion if position changed
@@ -797,6 +802,31 @@ class AppState extends ChangeNotifier {
);
}
// ---------- Utility Methods ----------
/// Generate a default changeset comment for a submission
/// Handles special case of <Existing tags> profile by using "a" instead
static String generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,
}) {
// Handle temp profiles with brackets by using "a"
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
? 'a'
: profile?.name ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {

View File

@@ -22,6 +22,7 @@ class PendingUpload {
final NodeProfile? profile;
final OperatorProfile? operatorProfile;
final Map<String, String> refinedTags; // User-selected values for empty profile tags
final String changesetComment; // User-editable changeset comment
final UploadMode uploadMode; // Capture upload destination when queued
final UploadOperation operation; // Type of operation: create, modify, or delete
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
@@ -45,6 +46,7 @@ class PendingUpload {
this.profile,
this.operatorProfile,
Map<String, String>? refinedTags,
required this.changesetComment,
required this.uploadMode,
required this.operation,
this.originalNodeId,
@@ -269,6 +271,7 @@ class PendingUpload {
'profile': profile?.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'refinedTags': refinedTags,
'changesetComment': changesetComment,
'uploadMode': uploadMode.index,
'operation': operation.index,
'originalNodeId': originalNodeId,
@@ -299,6 +302,7 @@ class PendingUpload {
refinedTags: j['refinedTags'] != null
? Map<String, String>.from(j['refinedTags'])
: {}, // Default empty map for legacy entries
changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
@@ -338,5 +342,25 @@ class PendingUpload {
if (error) return UploadState.error;
return UploadState.pending;
}
/// Generate a default changeset comment for legacy uploads that don't have one
static String _generateLegacyComment(Map<String, dynamic> j) {
final operation = j['operation'] != null
? UploadOperation.values[j['operation']]
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create);
final profileName = j['profile']?['name'] ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
}

View File

@@ -71,12 +71,13 @@ class Uploader {
break;
}
final profileName = p.profile?.name ?? 'surveillance';
// Use the user's changeset comment, with XML sanitization
final sanitizedComment = _sanitizeXmlText(p.changesetComment);
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action $profileName surveillance node"/>
<tag k="comment" v="$sanitizedComment"/>
</changeset>
</osm>''';
@@ -371,6 +372,21 @@ class Uploader {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
};
/// Sanitize text for safe inclusion in XML attributes and content
/// Removes or escapes characters that could break XML parsing
String _sanitizeXmlText(String input) {
return input
.replaceAll('&', '&amp;') // Must be first to avoid double-escaping
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
.replaceAll('\n', ' ') // Replace newlines with spaces
.replaceAll('\r', ' ') // Replace carriage returns with spaces
.replaceAll('\t', ' ') // Replace tabs with spaces
.trim(); // Remove leading/trailing whitespace
}
}
extension StringExtension on String {

View File

@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/osm_node.dart';
import '../models/pending_upload.dart'; // For UploadOperation enum
// ------------------ AddNodeSession ------------------
class AddNodeSession {
@@ -13,6 +14,7 @@ class AddNodeSession {
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
Map<String, String> refinedTags; // User-selected values for empty profile tags
String changesetComment; // User-editable changeset comment
AddNodeSession({
this.profile,
@@ -20,9 +22,11 @@ class AddNodeSession {
this.operatorProfile,
this.target,
Map<String, String>? refinedTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {};
refinedTags = refinedTags ?? {},
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
@@ -46,6 +50,7 @@ class EditNodeSession {
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
bool extractFromWay; // True if user wants to extract this constrained node
Map<String, String> refinedTags; // User-selected values for empty profile tags
String changesetComment; // User-editable changeset comment
EditNodeSession({
required this.originalNode,
@@ -56,9 +61,11 @@ class EditNodeSession {
required this.target,
this.extractFromWay = false,
Map<String, String>? refinedTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {};
refinedTags = refinedTags ?? {},
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0
@@ -82,7 +89,9 @@ class SessionState extends ChangeNotifier {
void startAddSession(List<NodeProfile> enabledProfiles) {
// Start with no profile selected - force user to choose
_session = AddNodeSession();
_session = AddNodeSession(
changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected
);
_editSession = null; // Clear any edit session
notifyListeners();
}
@@ -106,6 +115,7 @@ class SessionState extends ChangeNotifier {
operatorProfile: _detectedOperatorProfile,
initialDirection: initialDirection,
target: node.coord,
changesetComment: 'Update a surveillance node', // Default comment for existing tags profile
);
// Replace the default single direction with all existing directions (or empty list)
@@ -131,6 +141,7 @@ class SessionState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
String? changesetComment,
}) {
if (_session == null) return;
@@ -141,6 +152,11 @@ class SessionState extends ChangeNotifier {
}
if (profile != null && profile != _session!.profile) {
_session!.profile = profile;
// Regenerate changeset comment when profile changes
_session!.changesetComment = _generateDefaultChangesetComment(
profile: profile,
operation: UploadOperation.create,
);
dirty = true;
}
if (operatorProfile != _session!.operatorProfile) {
@@ -155,6 +171,10 @@ class SessionState extends ChangeNotifier {
_session!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (changesetComment != null) {
_session!.changesetComment = changesetComment;
dirty = true;
}
if (dirty) notifyListeners();
}
@@ -165,6 +185,7 @@ class SessionState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
String? changesetComment,
}) {
if (_editSession == null) return;
@@ -189,6 +210,13 @@ class SessionState extends ChangeNotifier {
_editSession!.operatorProfile = _detectedOperatorProfile;
}
// Regenerate changeset comment when profile changes
final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify;
_editSession!.changesetComment = _generateDefaultChangesetComment(
profile: profile,
operation: operation,
);
dirty = true;
}
// Only update operator profile if explicitly provided or different from current
@@ -214,6 +242,10 @@ class SessionState extends ChangeNotifier {
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (changesetComment != null) {
_editSession!.changesetComment = changesetComment;
dirty = true;
}
if (dirty) notifyListeners();
@@ -341,4 +373,27 @@ class SessionState extends ChangeNotifier {
_editSession!.currentDirectionIndex = 0;
}
}
/// Generate a default changeset comment for a submission
/// Handles special case of <Existing tags> profile by using "a" instead
String _generateDefaultChangesetComment({
required NodeProfile? profile,
required UploadOperation operation,
}) {
// Handle temp profiles with brackets by using "a"
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
? 'a'
: profile?.name ?? 'surveillance';
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
return 'Extract $profileName surveillance node';
}
}
}

View File

@@ -130,6 +130,7 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: UploadOperation.create,
);
@@ -187,6 +188,7 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: operation,
originalNodeId: session.originalNode.id, // Track which node we're editing
@@ -256,6 +258,7 @@ class UploadQueueState extends ChangeNotifier {
coord: node.coord,
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID
changesetComment: 'Delete a surveillance node', // Default comment for deletions
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,

View File

@@ -454,6 +454,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
selectedOperatorProfile: session.operatorProfile,
selectedProfile: session.profile,
currentRefinedTags: session.refinedTags,
operation: UploadOperation.create,
),
fullscreenDialog: true,
),
@@ -462,6 +463,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
appState.updateSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
);
}
}

View File

@@ -238,6 +238,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
profile: session.profile,
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
changesetComment: session.changesetComment, // Required parameter
uploadMode: UploadMode.production, // Mode doesn't matter for tag combination
operation: UploadOperation.modify,
);
@@ -491,6 +492,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
selectedProfile: session.profile,
currentRefinedTags: session.refinedTags,
originalNodeTags: session.originalNode.tags,
operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify,
),
fullscreenDialog: true,
),
@@ -505,11 +507,13 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
profile: updatedProfile,
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
);
} else {
appState.updateEditSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
);
}
}

View File

@@ -12,11 +12,13 @@ class RefineTagsResult {
final OperatorProfile? operatorProfile;
final Map<String, String> refinedTags;
final Map<String, String>? editedTags; // For existing tags profile mode
final String changesetComment; // User-editable changeset comment
RefineTagsResult({
required this.operatorProfile,
required this.refinedTags,
this.editedTags,
required this.changesetComment,
});
}
@@ -27,12 +29,14 @@ class RefineTagsSheet extends StatefulWidget {
this.selectedProfile,
this.currentRefinedTags,
this.originalNodeTags,
required this.operation,
});
final OperatorProfile? selectedOperatorProfile;
final NodeProfile? selectedProfile;
final Map<String, String>? currentRefinedTags;
final Map<String, String>? originalNodeTags;
final UploadOperation operation;
@override
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
@@ -44,6 +48,9 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
// For existing tags profile: full tag editing
late List<MapEntry<String, String>> _editableTags;
// Changeset comment editing
late final TextEditingController _commentController;
@override
void initState() {
@@ -60,6 +67,19 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
} else {
_editableTags = [];
}
// Initialize changeset comment with default
final defaultComment = AppState.generateDefaultChangesetComment(
profile: widget.selectedProfile,
operation: widget.operation,
);
_commentController = TextEditingController(text: defaultComment);
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
/// Pre-populate refined tags with existing values from the original node
@@ -113,6 +133,7 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
operatorProfile: widget.selectedOperatorProfile,
refinedTags: widget.currentRefinedTags ?? {},
editedTags: _isExistingTagsMode ? widget.selectedProfile?.tags : null,
changesetComment: _commentController.text,
)),
),
actions: [
@@ -126,6 +147,7 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
operatorProfile: _selectedOperatorProfile,
refinedTags: _refinedTags,
editedTags: editedTags,
changesetComment: _commentController.text,
));
},
child: Text(locService.t('refineTagsSheet.done')),
@@ -133,7 +155,12 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
],
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
Text(
locService.t('refineTagsSheet.operatorProfile'),
@@ -253,6 +280,25 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
...(_isExistingTagsMode
? _buildExistingTagsEditingSection(locService)
: _buildRefinableTagsSection(locService)),
// Changeset comment section
const SizedBox(height: 16),
Text(
'Change Comment',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Describe your changes...',
border: OutlineInputBorder(),
isDense: true,
),
maxLines: 2,
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
],
),
);