Skip to content

Commit 8dac884

Browse files
authored
Merge pull request #36 from corbett-lab/edit-log-undo
Add edit log and conflict-aware undo. Fix lineage labels in tree to reflect lineage panel
2 parents 88fe723 + 03eccce commit 8dac884

File tree

4 files changed

+390
-5
lines changed

4 files changed

+390
-5
lines changed

ui/linolium/taxonium_backend/server.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ waitForTheImports = async () => {
130130
var processedData = null;
131131
var cached_starting_values = null;
132132
var originalLineages = new Map();
133+
var editHistory = []; // Stack of edit operations for undo
133134

134135
function 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
9981145
app.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({

ui/linolium/taxonium_component/src/Taxonium.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ function Taxonium({
181181

182182
setSelectedLineage(null);
183183
refreshLineageData();
184+
refreshTreeData();
185+
fetchEditHistory();
184186

185187
toast.success(`Merged "${lineageName}" into "${result.parentLineage}" (${result.mergedCount} nodes)`, {
186188
duration: 4000,
@@ -249,8 +251,10 @@ function Taxonium({
249251
// Clear any existing lineage selection since the lineage has changed
250252
setSelectedLineage(null);
251253

252-
// Refresh the lineage data to reflect the changes
254+
// Refresh the lineage data and tree view to reflect the changes
253255
refreshLineageData();
256+
refreshTreeData();
257+
fetchEditHistory();
254258

255259
console.log(`SUCCESS: ${result.message}`);
256260
console.log(`Assigned to ${result.assignedCount} nodes, cleared from ${result.clearedCount} nodes under root ${nodeIdStr}`);
@@ -345,7 +349,7 @@ function Taxonium({
345349
[updateQuery]
346350
);
347351

348-
const { data, boundsForQueries, isCurrentlyOutsideBounds } =
352+
const { data, boundsForQueries, isCurrentlyOutsideBounds, refreshTreeData } =
349353
useGetDynamicData(
350354
backend,
351355
colorBy,
@@ -389,6 +393,50 @@ function Taxonium({
389393
// Get full lineage data for LineageTools (not subsampled keyStuff)
390394
const { lineageData: fullLineageData, isLoading, error, refreshData: refreshLineageData } = useFullLineageData(backend, 'meta_annotation_1');
391395

396+
// Edit history state
397+
const [editHistory, setEditHistory] = useState<Array<{
398+
id: number;
399+
action: string;
400+
lineageName: string;
401+
description: string;
402+
timestamp: string;
403+
affectedLineages?: string[];
404+
}>>([]);
405+
406+
const fetchEditHistory = useCallback(async () => {
407+
if (backend?.type !== 'server' || !backend.backend_url) return;
408+
try {
409+
const response = await fetch(`${backend.backend_url}/edit-history`);
410+
if (response.ok) {
411+
setEditHistory(await response.json());
412+
}
413+
} catch (e) { /* non-critical */ }
414+
}, [backend]);
415+
416+
const handleUndo = useCallback(async (editId?: number) => {
417+
if (backend?.type !== 'server' || !backend.backend_url) return;
418+
try {
419+
const response = await fetch(`${backend.backend_url}/undo-edit`, {
420+
method: 'POST',
421+
headers: { 'Content-Type': 'application/json' },
422+
body: JSON.stringify({ id: editId }),
423+
});
424+
if (!response.ok) {
425+
const err = await response.json();
426+
throw new Error(err.error || 'Undo failed');
427+
}
428+
const result = await response.json();
429+
refreshLineageData();
430+
refreshTreeData();
431+
fetchEditHistory();
432+
toast.success(`Undid: ${result.undone}`, { duration: 3000, position: 'top-center' });
433+
} catch (error) {
434+
toast.error(`Undo failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
435+
duration: 4000, position: 'top-center',
436+
});
437+
}
438+
}, [backend, refreshLineageData, refreshTreeData, fetchEditHistory]);
439+
392440
// Initialize colorHook with lineage data for hierarchical coloring
393441
const colorHook = useColor(config, colorMapping, colorBy.colorByField, fullLineageData);
394442

@@ -459,6 +507,8 @@ function Taxonium({
459507
deckSize={deckSize}
460508
boundsForQueries={boundsForQueries}
461509
pipelineDownloads={pipelineDownloads}
510+
editHistory={editHistory}
511+
onUndo={handleUndo}
462512
/>
463513

464514
<div className="flex flex-col md:flex-row overflow-hidden flex-grow">

0 commit comments

Comments
 (0)