Fix changesets not getting closed, other updates to queue mechanism

This commit is contained in:
stopflock
2025-12-01 15:01:48 -06:00
parent 560a5db14d
commit dccafc898b
19 changed files with 1072 additions and 136 deletions
+10
View File
@@ -203,6 +203,10 @@ class ChangelogService {
versionsNeedingMigration.add('1.3.1');
}
if (needsMigration(lastSeenVersion, currentVersion, '1.5.3')) {
versionsNeedingMigration.add('1.5.3');
}
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0');
@@ -268,6 +272,12 @@ class ChangelogService {
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
break;
case '1.5.3':
// Migrate upload queue to new two-stage changeset system
await appState.migrateUploadQueueToTwoStageSystem();
debugPrint('[ChangelogService] 1.5.3 migration completed: migrated upload queue to two-stage system');
break;
// Future migrations can be added here
// case '2.0.0':
// await appState.doSomethingNew();
+16
View File
@@ -66,6 +66,22 @@ class NodeCache {
}
}
/// Remove the _pending_deletion marker from a specific node (when deletion is cancelled)
void removePendingDeletionMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_deletion')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_deletion');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
+208 -60
View File
@@ -1,29 +1,60 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/pending_upload.dart';
import '../dev_config.dart';
import '../state/settings_state.dart';
import 'version_service.dart';
import '../app_state.dart';
class UploadResult {
final bool success;
final String? errorMessage;
final String? changesetId; // For changeset creation results
final int? nodeId; // For node operation results
final bool changesetNotFound; // Special flag for 404 case during close
UploadResult.success({
this.changesetId,
this.nodeId,
}) : success = true, errorMessage = null, changesetNotFound = false;
UploadResult.failure({
required this.errorMessage,
this.changesetNotFound = false,
this.changesetId,
this.nodeId,
}) : success = false;
// Legacy compatibility for simulate mode and full upload method
bool get isFullySuccessful => success;
bool get changesetCreateSuccess => success;
bool get nodeOperationSuccess => success;
bool get changesetCloseSuccess => success;
bool get hasOrphanedChangeset => changesetId != null && !success;
}
class Uploader {
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
Uploader(this.accessToken, this.onSuccess, this.onError, {this.uploadMode = UploadMode.production});
final String accessToken;
final void Function(int nodeId) onSuccess;
final void Function(String errorMessage) onError;
final UploadMode uploadMode;
Future<bool> upload(PendingUpload p) async {
// Create changeset (step 1 of 3)
Future<UploadResult> createChangeset(PendingUpload p) async {
try {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
debugPrint('[Uploader] Creating changeset for ${p.operation.name} operation...');
// Safety check: create, modify, and extract operations MUST have profiles
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify || p.operation == UploadOperation.extract) && p.profile == null) {
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
return false;
final errorMsg = 'Missing profile data for ${p.operation.name} operation';
debugPrint('[Uploader] ERROR - $errorMsg');
return UploadResult.failure(errorMessage: errorMsg);
}
// 1. open changeset
// Generate changeset XML
String action;
switch (p.operation) {
case UploadOperation.create:
@@ -39,7 +70,7 @@ class Uploader {
action = 'Extract';
break;
}
// Generate appropriate comment based on operation type
final profileName = p.profile?.name ?? 'surveillance';
final csXml = '''
<osm>
@@ -48,17 +79,38 @@ class Uploader {
<tag k="comment" v="$action $profileName surveillance node"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');
debugPrint('[Uploader] Creating changeset...');
final csResp = await _put('/api/0.6/changeset/create', csXml);
print('Uploader: Changeset response: ${csResp.statusCode} - ${csResp.body}');
debugPrint('[Uploader] Changeset response: ${csResp.statusCode} - ${csResp.body}');
if (csResp.statusCode != 200) {
print('Uploader: Failed to create changeset');
return false;
final errorMsg = 'Failed to create changeset: HTTP ${csResp.statusCode} - ${csResp.body}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg);
}
final csId = csResp.body.trim();
print('Uploader: Created changeset ID: $csId');
debugPrint('[Uploader] Created changeset ID: $csId');
return UploadResult.success(changesetId: csId);
} on TimeoutException catch (e) {
final errorMsg = 'Changeset creation timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg);
} catch (e) {
final errorMsg = 'Changeset creation failed with unexpected error: $e';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg);
}
}
// 2. create, update, or delete node
// Perform node operation (step 2 of 3)
Future<UploadResult> performNodeOperation(PendingUpload p, String changesetId) async {
try {
debugPrint('[Uploader] Performing ${p.operation.name} operation with changeset $changesetId');
final http.Response nodeResp;
final String nodeId;
@@ -70,34 +122,36 @@ class Uploader {
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Creating new node...');
debugPrint('[Uploader] Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
break;
case UploadOperation.modify:
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
debugPrint('[Uploader] Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
debugPrint('[Uploader] Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
final errorMsg = 'Failed to fetch node ${p.originalNodeId}: HTTP ${currentNodeResp.statusCode} - ${currentNodeResp.body}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
final errorMsg = 'Could not parse version from node XML: ${currentNodeXml.length > 200 ? currentNodeXml.substring(0, 200) + "..." : currentNodeXml}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
debugPrint('[Uploader] Current node version: $currentVersion');
// Update existing node with version
final mergedTags = p.getCombinedTags();
@@ -105,86 +159,181 @@ class Uploader {
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
debugPrint('[Uploader] Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
case UploadOperation.delete:
// First, fetch the current node to get its version and coordinates
print('Uploader: Fetching current node ${p.originalNodeId} for deletion...');
// First, fetch the current node to get its version
debugPrint('[Uploader] Fetching current node ${p.originalNodeId} for deletion...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
debugPrint('[Uploader] Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
final errorMsg = 'Failed to fetch node ${p.originalNodeId} for deletion: HTTP ${currentNodeResp.statusCode} - ${currentNodeResp.body}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
// Parse version and tags from the response XML
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
final errorMsg = 'Could not parse version from node XML for deletion: ${currentNodeXml.length > 200 ? currentNodeXml.substring(0, 200) + "..." : currentNodeXml}';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
debugPrint('[Uploader] Current node version: $currentVersion');
// Delete node - OSM requires current tags and coordinates
// Delete node - OSM requires current coordinates but empty tags
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
</node>
</osm>''';
print('Uploader: Deleting node ${p.originalNodeId}...');
debugPrint('[Uploader] Deleting node ${p.originalNodeId}...');
nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
break;
case UploadOperation.extract:
// Extract creates a new node with tags from the original node
// The new node is created at the session's target coordinates
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Extracting node from ${p.originalNodeId} to create new node...');
debugPrint('[Uploader] Extracting node from ${p.originalNodeId} to create new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
break;
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
debugPrint('[Uploader] Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
if (nodeResp.statusCode != 200) {
print('Uploader: Failed to ${p.operation.name} node');
return false;
final errorMsg = 'Failed to ${p.operation.name} node: HTTP ${nodeResp.statusCode} - ${nodeResp.body}';
debugPrint('[Uploader] $errorMsg');
// Note: changeset is included so caller knows to close it
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId');
// 3. close changeset
print('Uploader: Closing changeset...');
final closeResp = await _put('/api/0.6/changeset/$csId/close', '');
print('Uploader: Close response: ${closeResp.statusCode}');
print('Uploader: Upload successful!');
final nodeIdInt = int.parse(nodeId);
debugPrint('[Uploader] ${p.operation.name.capitalize()} node ID: $nodeIdInt');
// Notify success callback for immediate UI feedback
onSuccess(nodeIdInt);
return true;
return UploadResult.success(nodeId: nodeIdInt);
} on TimeoutException catch (e) {
final errorMsg = 'Node operation timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
} catch (e) {
print('Uploader: Upload failed with error: $e');
return false;
final errorMsg = 'Node operation failed with unexpected error: $e';
debugPrint('[Uploader] $errorMsg');
return UploadResult.failure(errorMessage: errorMsg, changesetId: changesetId);
}
}
// Close changeset (step 3 of 3)
Future<UploadResult> closeChangeset(String changesetId) async {
try {
debugPrint('[Uploader] Closing changeset $changesetId...');
final closeResp = await _put('/api/0.6/changeset/$changesetId/close', '');
debugPrint('[Uploader] Close response: ${closeResp.statusCode} - ${closeResp.body}');
switch (closeResp.statusCode) {
case 200:
debugPrint('[Uploader] Changeset closed successfully');
return UploadResult.success();
case 409:
// Conflict - check if changeset is already closed
if (closeResp.body.toLowerCase().contains('already closed') ||
closeResp.body.toLowerCase().contains('closed at')) {
debugPrint('[Uploader] Changeset already closed');
return UploadResult.success();
} else {
// Other conflict - keep retrying
final errorMsg = 'Changeset close conflict: HTTP ${closeResp.statusCode} - ${closeResp.body}';
return UploadResult.failure(errorMessage: errorMsg);
}
case 404:
// Changeset not found - this suggests the upload may not have worked
debugPrint('[Uploader] Changeset not found - marking for full retry');
return UploadResult.failure(
errorMessage: 'Changeset not found: HTTP 404',
changesetNotFound: true,
);
default:
// Other errors - keep retrying
final errorMsg = 'Failed to close changeset $changesetId: HTTP ${closeResp.statusCode} - ${closeResp.body}';
return UploadResult.failure(errorMessage: errorMsg);
}
} on TimeoutException catch (e) {
final errorMsg = 'Changeset close timed out after ${kUploadHttpTimeout.inSeconds}s: $e';
return UploadResult.failure(errorMessage: errorMsg);
} catch (e) {
final errorMsg = 'Changeset close failed with unexpected error: $e';
return UploadResult.failure(errorMessage: errorMsg);
}
}
// Legacy full upload method (primarily for simulate mode compatibility)
Future<UploadResult> upload(PendingUpload p) async {
debugPrint('[Uploader] Starting full upload for ${p.operation.name} at ${p.coord.latitude}, ${p.coord.longitude}');
// Step 1: Create changeset
final createResult = await createChangeset(p);
if (!createResult.success) {
onError(createResult.errorMessage!);
return createResult;
}
final changesetId = createResult.changesetId!;
// Step 2: Perform node operation
final nodeResult = await performNodeOperation(p, changesetId);
if (!nodeResult.success) {
onError(nodeResult.errorMessage!);
// Note: nodeResult includes changesetId for caller to close if needed
return nodeResult;
}
// Step 3: Close changeset
final closeResult = await closeChangeset(changesetId);
if (!closeResult.success) {
// Node operation succeeded but changeset close failed
// Don't call onError since node operation worked
debugPrint('[Uploader] Node operation succeeded but changeset close failed');
return UploadResult.failure(
errorMessage: closeResult.errorMessage,
changesetNotFound: closeResult.changesetNotFound,
changesetId: changesetId,
nodeId: nodeResult.nodeId,
);
}
// All steps successful
debugPrint('[Uploader] Full upload completed successfully');
return UploadResult.success(
changesetId: changesetId,
nodeId: nodeResult.nodeId,
);
}
String get _host {
switch (uploadMode) {
case UploadMode.sandbox:
@@ -198,25 +347,25 @@ class Uploader {
Future<http.Response> _get(String path) => http.get(
Uri.https(_host, path),
headers: _headers,
);
).timeout(kUploadHttpTimeout);
Future<http.Response> _post(String path, String body) => http.post(
Uri.https(_host, path),
headers: _headers,
body: body,
);
).timeout(kUploadHttpTimeout);
Future<http.Response> _put(String path, String body) => http.put(
Uri.https(_host, path),
headers: _headers,
body: body,
);
).timeout(kUploadHttpTimeout);
Future<http.Response> _delete(String path, String body) => http.delete(
Uri.https(_host, path),
headers: _headers,
body: body,
);
).timeout(kUploadHttpTimeout);
Map<String, String> get _headers => {
'Authorization': 'Bearer $accessToken',
@@ -228,5 +377,4 @@ extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}
}