d121edc0bd2809f1d6de6185a497e6a288958479
braney
  Tue May 12 09:18:29 2026 -0700
hgConvert quickLift: skip pre-lifted tracks, append-and-merge hub file, per-track remove UI, refs #37535

In hgConvert / trackHubBuild:
- Skip tracks that already came from a quickLift hub (quickLiftUrl / quickLifted setting) so they don't get re-lifted to a new destination.
- Append new track stanzas to an existing per-source hub file instead of overwriting it; new stanzas get priorities after the existing max; duplicate track names are skipped. Also avoids re-emitting a parent supertrack that's already in the file.

New public quickLiftHubRemoveTrack(cart, sourceDb, trackName) in trackHub.c rewrites the per-source hub file with the named stanza removed plus all descendant stanzas (parent reference cascade, transitive).

hgTrackUi: adds a "Remove from QuickLift" link next to "Duplicate track" for any tdb carrying a quickLiftDb setting. The link hits hgTrackUi_op=quickLiftRemove which calls quickLiftHubRemoveTrack, hides the track in the cart, and 302s to hgTracks. The op argument cart var is qlSourceDb (renamed from quickLiftSourceDb to avoid colliding with the quickLift.* prefix used elsewhere; values cloned out of the cart hash before cartRemove so the helper doesn't see freed strings).

hgTracks: adds a small "x" icon (printQuickLiftDelIcon) on tracks in a quickLift group, suppressed on the synthetic bigQuickLiftChain track. JS onQuickLiftDelIconClick fires the same hgTrackUi_op endpoint via synchronous XHR and removes every TD whose icon matches the deleted data-track, so the row goes away in both the QuickLift group and the Visible Tracks group.

hubConnect cart handling fixes shaken out by the above:
- hubConnectRemakeTrackHubVar's cart-var prefix is now "quickLift." (with the trailing dot) instead of "quickLift", so unrelated keys like qlSourceDb no longer get parsed as hubId/db and crash cart loading on every CGI. Also skips entries whose hubStatus lookup returned NULL.
- hubConnectStatusListFromCart no longer calls removeQuickListReference when the current db isn't the lift's destination. A side trip to another assembly between two lifts to the same destination was deleting the earlier attachment's cart var; just skip attaching this load and leave the cart alone so the lift survives the round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git src/hg/lib/trackHub.c src/hg/lib/trackHub.c
index 5dfa88c8d31..593c8a950b3 100644
--- src/hg/lib/trackHub.c
+++ src/hg/lib/trackHub.c
@@ -45,30 +45,31 @@
 #include "hubConnect.h"
 #include "trix.h"
 #include "vcf.h"
 #include "vcfUi.h"
 #include "htmshell.h"
 #include "bigBedFind.h"
 #include "customComposite.h"
 #include "interactUi.h"
 #include "bedTabix.h"
 #include "hic.h"
 #include "hui.h"
 #include "chromAlias.h"
 #include "trashDir.h"
 #include "hgConfig.h"
 #include "cartTrackDb.h"
+#include "quickLift.h"
 
 #ifdef USE_HAL
 #include "halBlockViz.h"
 #endif
 
 struct grp *trackHubGrps = NULL;   // global with grps loaded from track hubs
 static struct hash *hubCladeHash;  // mapping of clade name to hub pointer
 static struct hash *hubAssemblyHash; // mapping of assembly name to genome struct
 static struct hash *hubAssemblyUndecoratedHash; // mapping of undecorated assembly name to genome struct
 static struct hash *hubOrgHash;   // mapping from organism name to hub pointer
 static struct trackHub *globalAssemblyHubList; // list of trackHubs in the user's cart
 static struct hash *trackHubHash;
 
 static boolean isValidSeqNameChar(char c)
 /* Return TRUE if c is a valid character for a sequence name: [A-Za-z0-9._-]. */
@@ -1548,30 +1549,153 @@
 
 // we don't reuse userdata paths since they are in save sessions
 if ((hubName != NULL) && strstr(hubName, "userdata"))
     hubName = NULL;
 
 if ((hubName == NULL) || ((fd = open(hubName, 0)) < 0))
     {
     trashDirDateFile(&hubTn, "quickLift", "hub", ".txt");
     hubName = cloneString(hubTn.forCgi);
     cartSetString(cart, buffer, hubName);
     }
 
 return hubName;
 }
 
+struct quickLiftStanza
+/* One track stanza parsed out of a quickLift hub file. */
+    {
+    struct quickLiftStanza *next;
+    char *name;                /* bare track name */
+    char *parent;              /* bare parent track name, or NULL */
+    struct dyString *text;     /* full stanza text including final newline */
+    };
+
+static char *firstWordClone(char *s)
+/* Return a clone of the first whitespace-delimited word of s, or NULL. */
+{
+s = skipLeadingSpaces(s);
+if (isEmpty(s))
+    return NULL;
+char *sp = skipToSpaces(s);
+int len = (sp != NULL) ? (sp - s) : (int)strlen(s);
+return cloneStringZ(s, len);
+}
+
+boolean quickLiftHubRemoveTrack(struct cart *cart, char *sourceDb, char *trackName)
+/* Remove a track stanza from the quickLift hub file for sourceDb, along with
+ * any descendant stanzas (transitively) whose parent is being removed.
+ * Returns TRUE if at least one stanza was removed. */
+{
+char buffer[4096];
+safef(buffer, sizeof buffer, "%s-%s", quickLiftCartName, sourceDb);
+char *filename = cartOptionalString(cart, buffer);
+if (filename == NULL)
+    return FALSE;
+
+struct lineFile *lf = lineFileMayOpen(filename, TRUE);
+if (lf == NULL)
+    return FALSE;
+
+char *bareName = trackHubSkipHubName(trackName);
+struct dyString *header = dyStringNew(0);
+struct quickLiftStanza *stanzaList = NULL;
+struct quickLiftStanza *cur = NULL;
+char *line;
+int lineSize;
+
+/* Pass 1: read the file into a header + list of stanzas, recording each
+ * stanza's name and (if any) parent. */
+while (lineFileNext(lf, &line, &lineSize))
+    {
+    char *trim = skipLeadingSpaces(line);
+    if (startsWithWord("track", trim))
+        {
+        AllocVar(cur);
+        cur->text = dyStringNew(0);
+        cur->name = firstWordClone(trim + 5);
+        slAddHead(&stanzaList, cur);
+        }
+    else if (cur != NULL && startsWithWord("parent", trim))
+        {
+        if (cur->parent == NULL)
+            cur->parent = firstWordClone(trim + 6);
+        }
+
+    struct dyString *target = (cur != NULL) ? cur->text : header;
+    dyStringAppend(target, line);
+    dyStringAppendC(target, '\n');
+    }
+slReverse(&stanzaList);
+lineFileClose(&lf);
+
+/* Build a removal set: start with the named track, then iterate adding any
+ * stanza whose parent is already in the set, until the set is stable. */
+struct hash *removeSet = hashNew(0);
+hashStore(removeSet, bareName);
+boolean changed = TRUE;
+while (changed)
+    {
+    changed = FALSE;
+    struct quickLiftStanza *s;
+    for (s = stanzaList; s != NULL; s = s->next)
+        {
+        if (s->name == NULL || s->parent == NULL)
+            continue;
+        if (hashLookup(removeSet, s->name) != NULL)
+            continue;
+        if (hashLookup(removeSet, s->parent) != NULL)
+            {
+            hashStore(removeSet, s->name);
+            changed = TRUE;
+            }
+        }
+    }
+
+boolean removedAny = FALSE;
+struct dyString *out = dyStringNew(0);
+struct quickLiftStanza *s;
+for (s = stanzaList; s != NULL; s = s->next)
+    {
+    if (s->name != NULL && hashLookup(removeSet, s->name) != NULL)
+        removedAny = TRUE;
+    else
+        dyStringAppend(out, s->text->string);
+    }
+
+if (removedAny)
+    {
+    FILE *f = mustOpen(filename, "w");
+    chmod(filename, 0666);
+    fputs(header->string, f);
+    fputs(out->string, f);
+    fclose(f);
+    }
+
+dyStringFree(&header);
+dyStringFree(&out);
+hashFree(&removeSet);
+for (s = stanzaList; s != NULL; s = s->next)
+    {
+    dyStringFree(&s->text);
+    freeMem(s->name);
+    freeMem(s->parent);
+    }
+slFreeList(&stanzaList);
+return removedAny;
+}
+
 static char *vettedTracks[] =
 /* tracks that have been tested with quickLift */
 {
 "decipherContainer",
 "decipherSnvs",
 "omimLocation",
 "omimAvSnp",
 "ncbiRefSeq",
 "clinvar",
 "clinvarSubLolly",
 "pubs",
 "pubsBlat",
 "pubsMarkerBand",
 "pubsMarkerSnp",
 "pubsMarkerGene",
@@ -1843,123 +1967,189 @@
 safef(buffer, sizeof buffer, "%d", priority);
 hashReplace(tdb->settingsHash, "priority", cloneString(buffer));
 
 struct dyString *dy = trackDbString(cart, tdb);
 fprintf(f, "%s\n", dy->string);
 }
 
 static boolean checkCartVisibility(struct cart *cart, struct trackDb *tdb)
 {
 char *cartVis = cartOptionalString(cart, tdb->track);
 if (cartVis != NULL)
     tdb->visibility = hTvFromString(cartVis);
 return (tdb->visibility != tvHide);
 }
 
-static void walkTree(FILE *f, char *db, struct cart *cart,  struct trackDb *tdb, struct dyString *visDy, struct trackDb **badList)
-/* walk tree looking for visible tracks to output to hub. */
+static boolean isFromQuickLiftHub(struct trackDb *tdb)
+/* True if this tdb came from a quickLift hub (already a lifted shadow track).
+ * Such tracks must not be lifted again. */
 {
-unsigned priority = 1;
+return trackDbSetting(tdb, "quickLiftUrl") != NULL ||
+       trackDbSetting(tdb, "quickLifted") != NULL;
+}
+
+static void walkTree(FILE *f, char *db, struct cart *cart,  struct trackDb *tdb, struct dyString *visDy, struct trackDb **badList, struct hash *existingTracks, unsigned startPriority)
+/* walk tree looking for visible tracks to output to hub.  Skip tracks that already
+ * came from a quickLift hub, and skip tracks whose name is already present in
+ * the existing hub file. */
+{
+unsigned priority = startPriority;
 struct hash *haveSuper = newHash(0);
 struct trackDb *tdbNext = NULL;
 
 for(; tdb; tdb = tdbNext)
     {
     tdbNext = tdb->next;
 
+    if (isFromQuickLiftHub(tdb))
+        continue;
+
+    if (existingTracks != NULL &&
+        hashLookup(existingTracks, trackHubSkipHubName(tdb->track)) != NULL)
+        continue;
+
     boolean isVisible =  FALSE;
 
     if (tdb->parent == NULL)
         isVisible = checkCartVisibility(cart, tdb);
     else if (isParentVisible(cart, tdb) &&  isSubtrackVisible(cart, tdb)) // child of supertrack
         {
         if (hashLookup(haveSuper, tdb->parent->track) == NULL)  // output yet?
             {
             //if (checkCartVisibility(cart, tdb->parent))
+                {
+                char *bareParent = trackHubSkipHubName(tdb->parent->track);
+                if (existingTracks == NULL ||
+                    hashLookup(existingTracks, bareParent) == NULL)
                     {
                     tdb->parent->visibility = hTvFromString("tvShow");
                     outTrack(f, cart, tdb->parent, priority++);
-
+                    }
                 hashStore(haveSuper, tdb->parent->track);
                 }
             }
         isVisible = checkCartVisibility(cart, tdb);
         }
 
     if (isVisible && validateTdb(cart, db, tdb, badList))
         {
         hashRemove(tdb->settingsHash, "superTrack");   // this gets inherited by subTracks(?)
 
         // is this a custom track?
         char *tdbType = trackDbSetting(tdb, "tdbType");
         if (tdbType != NULL)
             {
             hashReplace(tdb->settingsHash, "type", tdbType);
             hashReplace(tdb->settingsHash, "shortLabel", trackDbSetting(tdb, "name"));
             hashReplace(tdb->settingsHash, "longLabel", trackDbSetting(tdb, "description"));
             }
 
         outTrack(f, cart, tdb, priority++);
         }
     }
 }
 
+static void readExistingHubTracks(char *filename, struct hash *trackNames, unsigned *retMaxPriority)
+/* Scan an existing quickLift hub file and populate trackNames with the set of
+ * track names already present.  Also returns the highest priority value seen
+ * (0 if the file has no track stanzas yet) so new tracks can be appended after
+ * existing ones. */
+{
+unsigned maxPriority = 0;
+struct lineFile *lf = lineFileMayOpen(filename, TRUE);
+if (lf != NULL)
+    {
+    char *line;
+    while (lineFileNextReal(lf, &line))
+        {
+        if (startsWithWord("track", line))
+            {
+            char *name = skipLeadingSpaces(line + 5);
+            if (isNotEmpty(name))
+                hashStoreName(trackNames, cloneString(firstWordInLine(name)));
+            }
+        else if (startsWithWord("priority", line))
+            {
+            char *val = skipLeadingSpaces(line + 8);
+            if (isNotEmpty(val))
+                {
+                unsigned p = sqlUnsigned(firstWordInLine(val));
+                if (p > maxPriority)
+                    maxPriority = p;
+                }
+            }
+        }
+    lineFileClose(&lf);
+    }
+if (retMaxPriority != NULL)
+    *retMaxPriority = maxPriority;
+}
+
 static int cmpPriority(const void *va, const void *vb)
 /* Compare to sort based on priority; use shortLabel as secondary sort key. */
 {
 const struct trackDb *a = *((struct trackDb **)va);
 const struct trackDb *b = *((struct trackDb **)vb);
 float dif = 0;
 
 dif = a->groupPriority - b->groupPriority;
 if (dif == 0)
     dif = a->priority - b->priority;
 if (dif < 0)
    return -1;
 else if (dif == 0.0)
     /* secondary sort on label */
     return strcasecmp(a->shortLabel, b->shortLabel);
 else
    return 1;
 }
 
 char *trackHubBuild(char *db, struct cart *cart, struct dyString *visDy, struct trackDb **badList)
-/* Build a track hub using trackDb and the cart. */
+/* Build a track hub using trackDb and the cart.  If a hub file already exists
+ * for db (i.e. earlier quickLift work in the same session), append new track
+ * stanzas to it instead of overwriting, and skip tracks that are already in
+ * the file. */
 {
 struct  trackDb *tdbList, *tdb;
 struct grp *grpList;
 cartTrackDbInit(cart, &tdbList, &grpList, FALSE);
 
 struct hash *groupHash = newHash(0);
 struct grp *grp;
 for(grp = grpList; grp; grp = grp->next)
     hashAdd(groupHash, grp->name, grp);
 
 for(tdb = tdbList; tdb; tdb = tdb->next)
     {
     grp = hashFindVal(groupHash, tdb->grp);
     tdb->groupPriority = grp->priority;
     }
 slSort(&tdbList, cmpPriority);
 
 char *filename = getHubName(cart, db);
 
-FILE *f = mustOpen(filename, "w");
+struct hash *existingTracks = newHash(8);
+unsigned maxPriority = 0;
+readExistingHubTracks(filename, existingTracks, &maxPriority);
+boolean hubExists = (hashNumEntries(existingTracks) > 0);
+
+FILE *f = mustOpen(filename, hubExists ? "a" : "w");
 chmod(filename, 0666);
+if (!hubExists)
     outHubHeader(f, trackHubSkipHubName(db));
 
-walkTree(f, db, cart, tdbList, visDy, badList);
+walkTree(f, db, cart, tdbList, visDy, badList, existingTracks, maxPriority + 1);
 fclose(f);
 
 return cloneString(filename);
 }
 
 struct grp *trackHubGetGrps()
 /* Get the groups defined by attached track hubs. */
 {
 return trackHubGrps;
 }
 
 struct trackDb *trackHubAddTracksGenome(struct trackHubGenome *hubGenome)
 /* Load up stuff from data hub and return list. */
 {
 /* Load trackDb.ra file and make it into proper trackDb tree */