@@ -130,6 +130,7 @@ waitForTheImports = async () => {
130130var processedData = null ;
131131var cached_starting_values = null ;
132132var originalLineages = new Map ( ) ;
133+ var editHistory = [ ] ; // Stack of edit operations for undo
133134
134135function snapshotLineages ( ) {
135136 originalLineages . clear ( ) ;
@@ -895,6 +896,15 @@ app.post("/merge-lineage", function (req, res) {
895896 return nodeLineage === lineageName || nodeLineage . startsWith ( lineageName + '.' ) ;
896897 } ;
897898
899+ // Snapshot affected tips before the edit
900+ const affectedNodeIds = new Set ( ) ;
901+ processedData . nodes . forEach ( node => {
902+ if ( node . is_tip && node [ field ] && isLineageToMerge ( node [ field ] ) ) {
903+ affectedNodeIds . add ( node . node_id ) ;
904+ }
905+ } ) ;
906+ const snapshot = snapshotTips ( field , affectedNodeIds ) ;
907+
898908 let mergedCount = 0 ;
899909 const affectedLineages = new Set ( ) ;
900910
@@ -909,6 +919,19 @@ app.post("/merge-lineage", function (req, res) {
909919 // Rebuild clade labels to reflect the new tip assignments
910920 rebuildCladeLabels ( field ) ;
911921
922+ // Record in edit history
923+ const editEntry = {
924+ id : editHistory . length ,
925+ action : 'merge' ,
926+ lineageName,
927+ field,
928+ description : `Merged ${ lineageName } into ${ parentLineage } (${ mergedCount } nodes)` ,
929+ timestamp : new Date ( ) . toISOString ( ) ,
930+ snapshot,
931+ affectedLineages : [ lineageName , parentLineage , ...affectedLineages ] ,
932+ } ;
933+ editHistory . push ( editEntry ) ;
934+
912935 console . log ( `Merged ${ mergedCount } nodes into ${ parentLineage } in ${ Date . now ( ) - start_time } ms` ) ;
913936
914937 res . send ( {
@@ -994,6 +1017,130 @@ function rebuildCladeLabels(field) {
9941017 console . log ( `Rebuilt ${ cladeCount } clade labels in ${ Date . now ( ) - start } ms` ) ;
9951018}
9961019
1020+ // Snapshot current tip assignments for a set of node IDs (for undo)
1021+ function snapshotTips ( field , nodeIds ) {
1022+ const snapshot = { } ;
1023+ for ( const node of processedData . nodes ) {
1024+ if ( node . is_tip && nodeIds . has ( node . node_id ) ) {
1025+ snapshot [ node . node_id ] = node [ field ] || '' ;
1026+ }
1027+ }
1028+ return snapshot ;
1029+ }
1030+
1031+ // Restore tip assignments from a snapshot and rebuild clade labels
1032+ function restoreTipSnapshot ( field , snapshot ) {
1033+ for ( const node of processedData . nodes ) {
1034+ if ( node . is_tip && snapshot . hasOwnProperty ( node . node_id ) ) {
1035+ node [ field ] = snapshot [ node . node_id ] ;
1036+ }
1037+ }
1038+ rebuildCladeLabels ( field ) ;
1039+ }
1040+
1041+ // GET endpoint to retrieve edit history
1042+ app . get ( "/edit-history" , function ( req , res ) {
1043+ res . send ( editHistory . map ( entry => ( {
1044+ id : entry . id ,
1045+ action : entry . action ,
1046+ lineageName : entry . lineageName ,
1047+ description : entry . description ,
1048+ timestamp : entry . timestamp ,
1049+ affectedLineages : entry . affectedLineages || [ ] ,
1050+ } ) ) ) ;
1051+ } ) ;
1052+
1053+ // Compute which edits would need to be undone along with a target edit.
1054+ // An edit conflicts if it modified any of the same node IDs as the target
1055+ // or any other edit already in the conflict set (transitive closure).
1056+ function computeConflictingEdits ( targetId ) {
1057+ const targetIndex = editHistory . findIndex ( e => e . id === targetId ) ;
1058+ if ( targetIndex === - 1 ) return [ ] ;
1059+
1060+ const target = editHistory [ targetIndex ] ;
1061+ const taintedNodeIds = new Set ( Object . keys ( target . snapshot ) ) ;
1062+ const toUndo = [ targetId ] ;
1063+
1064+ // Walk forward from target+1, expanding the tainted set transitively
1065+ for ( let i = targetIndex + 1 ; i < editHistory . length ; i ++ ) {
1066+ const entry = editHistory [ i ] ;
1067+ const entryNodeIds = Object . keys ( entry . snapshot ) ;
1068+ const conflicts = entryNodeIds . some ( id => taintedNodeIds . has ( id ) ) ;
1069+ if ( conflicts ) {
1070+ toUndo . push ( entry . id ) ;
1071+ entryNodeIds . forEach ( id => taintedNodeIds . add ( id ) ) ;
1072+ }
1073+ }
1074+ return toUndo ;
1075+ }
1076+
1077+ // GET endpoint to preview which edits would be undone for a given edit id
1078+ app . get ( "/undo-preview/:id" , function ( req , res ) {
1079+ const targetId = parseInt ( req . params . id , 10 ) ;
1080+ const ids = computeConflictingEdits ( targetId ) ;
1081+ res . send ( { targetId, wouldUndo : ids } ) ;
1082+ } ) ;
1083+
1084+ // POST endpoint to undo an edit and any conflicting later edits
1085+ app . post ( "/undo-edit" , function ( req , res ) {
1086+ const { id } = req . body || { } ;
1087+
1088+ if ( editHistory . length === 0 ) {
1089+ return res . status ( 400 ) . send ( { error : "No edits to undo" } ) ;
1090+ }
1091+
1092+ // Default to last edit
1093+ const targetId = id !== undefined ? id : editHistory [ editHistory . length - 1 ] . id ;
1094+ const targetIndex = editHistory . findIndex ( e => e . id === targetId ) ;
1095+ if ( targetIndex === - 1 ) {
1096+ return res . status ( 404 ) . send ( { error : `Edit ${ targetId } not found` } ) ;
1097+ }
1098+
1099+ const idsToUndo = new Set ( computeConflictingEdits ( targetId ) ) ;
1100+
1101+ // Collect entries to undo (in history order) and entries to keep
1102+ const toUndo = [ ] ;
1103+ const toKeep = [ ] ;
1104+ for ( const entry of editHistory ) {
1105+ if ( idsToUndo . has ( entry . id ) ) {
1106+ toUndo . push ( entry ) ;
1107+ } else {
1108+ toKeep . push ( entry ) ;
1109+ }
1110+ }
1111+
1112+ // Restore snapshots in reverse order
1113+ for ( let i = toUndo . length - 1 ; i >= 0 ; i -- ) {
1114+ const entry = toUndo [ i ] ;
1115+ console . log ( `Undoing edit #${ entry . id } : ${ entry . description } ` ) ;
1116+ for ( const node of processedData . nodes ) {
1117+ if ( node . is_tip && entry . snapshot . hasOwnProperty ( node . node_id ) ) {
1118+ node [ entry . field ] = entry . snapshot [ node . node_id ] ;
1119+ }
1120+ }
1121+ }
1122+
1123+ // Rebuild clade labels once
1124+ rebuildCladeLabels ( toUndo [ 0 ] . field ) ;
1125+
1126+ // Replace history with only the kept entries (re-index ids)
1127+ editHistory . length = 0 ;
1128+ toKeep . forEach ( ( entry , i ) => {
1129+ entry . id = i ;
1130+ editHistory . push ( entry ) ;
1131+ } ) ;
1132+
1133+ console . log ( `Undid ${ toUndo . length } edit(s), kept ${ toKeep . length } ` ) ;
1134+
1135+ res . send ( {
1136+ success : true ,
1137+ undone : toUndo [ 0 ] . description ,
1138+ removedCount : toUndo . length ,
1139+ removedIds : toUndo . map ( e => e . id ) ,
1140+ remainingEdits : editHistory . length ,
1141+ } ) ;
1142+ } ) ;
1143+
9971144// POST endpoint to edit lineage root assignments based on selected tree node
9981145app . post ( "/edit-lineage-root" , function ( req , res ) {
9991146 const start_time = Date . now ( ) ;
@@ -1099,6 +1246,15 @@ app.post("/edit-lineage-root", function (req, res) {
10991246 cur = nodeLookup [ cur . parent_id ] ;
11001247 }
11011248
1249+ // Snapshot all tips that could be affected (in subtree + currently assigned outside)
1250+ const affectedNodeIds = new Set ( ) ;
1251+ processedData . nodes . forEach ( node => {
1252+ if ( ! node . is_tip ) return ;
1253+ if ( targetNodeIds . has ( node . node_id ) ) affectedNodeIds . add ( node . node_id ) ;
1254+ if ( ( node [ field ] || null ) === lineageName ) affectedNodeIds . add ( node . node_id ) ;
1255+ } ) ;
1256+ const snapshot = snapshotTips ( field , affectedNodeIds ) ;
1257+
11021258 let assignedCount = 0 ;
11031259 let clearedCount = 0 ;
11041260
@@ -1128,6 +1284,26 @@ app.post("/edit-lineage-root", function (req, res) {
11281284
11291285 rebuildCladeLabels ( field ) ;
11301286
1287+ // Collect all lineages this edit touched
1288+ const editAffectedLineages = new Set ( [ lineageName ] ) ;
1289+ if ( parentLineage ) editAffectedLineages . add ( parentLineage ) ;
1290+ for ( const val of Object . values ( snapshot ) ) {
1291+ if ( val ) editAffectedLineages . add ( val ) ;
1292+ }
1293+
1294+ // Record in edit history
1295+ const editEntry = {
1296+ id : editHistory . length ,
1297+ action : 'edit-root' ,
1298+ lineageName,
1299+ field,
1300+ description : `Moved ${ lineageName } root to node ${ rootNodeId } (${ assignedCount } assigned, ${ clearedCount } displaced)` ,
1301+ timestamp : new Date ( ) . toISOString ( ) ,
1302+ snapshot,
1303+ affectedLineages : [ ...editAffectedLineages ] ,
1304+ } ;
1305+ editHistory . push ( editEntry ) ;
1306+
11311307 console . log ( `Operation completed in ${ Date . now ( ) - start_time } ms` ) ;
11321308
11331309 res . send ( {
0 commit comments