630eb30bc3695afcf73a19be6e3bc9a2829b365f
chmalee
  Wed May 13 13:04:37 2026 -0700
Make myVariants items bed12+ rather than bed9+, refs #33808

diff --git src/hg/hgTracks/myVariantsTrack.c src/hg/hgTracks/myVariantsTrack.c
index 2f098d1408b..50fea157b19 100644
--- src/hg/hgTracks/myVariantsTrack.c
+++ src/hg/hgTracks/myVariantsTrack.c
@@ -14,32 +14,33 @@
 #include "binRange.h"
 #include "myVariants.h"
 #include "myVariantsShare.h"
 #include "jsonParse.h"
 #include "sqlNum.h"
 #include "customFactory.h"
 #include "hgConfig.h"
 #include "htmlColor.h"
 #include "wikiLink.h"
 #include "hgFind.h"
 #include "hgHgvs.h"
 #include "jsonWrite.h"
 
 static char *reservedFieldNames[] = {
     "bin", "chrom", "chromStart", "chromEnd", "name", "score", "strand",
-    "thickStart", "thickEnd", "itemRgb", "description", "db", "ref", "alt",
-    "project", "mouseover", "id", NULL
+    "thickStart", "thickEnd", "itemRgb", "blockCount", "blockSizes",
+    "chromStarts", "description", "db", "ref", "alt", "project",
+    "mouseover", "id", NULL
 };
 
 static char *truncateSeq(char *seq, int maxLen)
 /* Return seq cloned and truncated to maxLen with "..." appended if longer.
  * NULL or empty input returns NULL. */
 {
 if (isEmpty(seq))
     return NULL;
 if (strlen(seq) <= (size_t)maxLen)
     return cloneString(seq);
 struct dyString *dy = dyStringNew(maxLen + 4);
 dyStringAppendN(dy, seq, maxLen);
 dyStringAppend(dy, "...");
 return dyStringCannibalize(&dy);
 }
@@ -161,30 +162,96 @@
 for (i = 1; name[i] != '\0'; i++)
     {
     if (!isalnum(name[i]) && name[i] != '_')
         return FALSE;
     }
 /* Check against reserved names */
 int j;
 for (j = 0; reservedFieldNames[j] != NULL; j++)
     {
     if (sameString(name, reservedFieldNames[j]))
         return FALSE;
     }
 return TRUE;
 }
 
+static boolean hasTabOrNewline(char *s)
+/* TRUE if s contains \t, \n, or \r. */
+{
+return s != NULL && (strchr(s, '\t') != NULL ||
+                     strchr(s, '\n') != NULL ||
+                     strchr(s, '\r') != NULL);
+}
+
+static void validateItemBlocks(struct myVariants *item)
+/* Format item as a 12-column BED row and run it through loadAndValidateBed
+ * (isCt=TRUE) for the canonical block checks.  lineFileAbort fires errAbort
+ * on failure, the same error path used elsewhere in this file. */
+{
+/* Reject embedded tab/newline in chrom and name: lineFileNext would
+ * truncate the row and chopByChar would leave trailing row[] slots
+ * uninitialized for loadAndValidateBed to dereference. */
+if (hasTabOrNewline(item->chrom))
+    errAbort("chrom contains illegal whitespace");
+if (hasTabOrNewline(item->name))
+    errAbort("name contains illegal whitespace");
+
+struct dyString *dy = dyStringNew(256);
+int i;
+dyStringPrintf(dy, "%s\t%u\t%u\t%s\t%u\t%s\t%u\t%u\t%u\t%u\t",
+    item->chrom, item->chromStart, item->chromEnd,
+    isEmpty(item->name) ? "x" : item->name,
+    item->score, item->strand, item->thickStart, item->thickEnd,
+    item->itemRgb, item->blockCount);
+for (i = 0; i < item->blockCount; i++)
+    dyStringPrintf(dy, "%d,", item->blockSizes[i]);
+dyStringAppendC(dy, '\t');
+for (i = 0; i < item->blockCount; i++)
+    dyStringPrintf(dy, "%d,", item->chromStarts[i]);
+
+/* lineFileOnString takes the buffer by pointer, but lineFileClose does
+ * not free it (the fd<0 branch skips the free).  Hold the pointer
+ * separately and free it after lineFileClose. */
+char *buf = dyStringCannibalize(&dy);
+struct lineFile *lf = lineFileOnString("myVariantsBlocks", TRUE, buf);
+char *row[12];
+char *line = NULL;
+lineFileNext(lf, &line, NULL);
+int got = chopByChar(line, '\t', row, ArraySize(row));
+if (got != 12)
+    errAbort("validateItemBlocks: chopped %d fields, expected 12", got);
+struct bed tmp;
+ZeroVar(&tmp);
+loadAndValidateBed(row, 12, 12, lf, &tmp, NULL, TRUE);
+lineFileClose(&lf);
+freeMem(buf);
+freeMem(tmp.blockSizes);
+freeMem(tmp.chromStarts);
+}
+
+static void ensureItemBlocks(struct myVariants *item)
+/* If item has no blocks, synthesize a single full-span block. */
+{
+if (item->blockCount > 0)
+    return;
+item->blockCount = 1;
+AllocArray(item->blockSizes, 1);
+AllocArray(item->chromStarts, 1);
+item->blockSizes[0] = item->chromEnd - item->chromStart;
+item->chromStarts[0] = 0;
+}
+
 void myVariantsJsCommand(char *command, struct track *trackList, struct hash *trackHash)
 /* Execute some command sent to us from the javaScript.  All we know for sure is that
  * the first word of the command is "myVariants."  We expect it to be of format:
  *    myVariants <trackName> <jsonData>
  * where jsonData is a JSON string containing the item details */
 {
 if (!cfgOptionBooleanDefault("doMyVariants", FALSE))
     return;
 char *userName = getUserName();
 if (userName == NULL)
     {
     warn("You must be logged in to add an annotation.");
     return;
     }
 
@@ -351,30 +418,68 @@
         item->name = cloneString(name);
     item->score = score;
     item->strand[0] = strand[0];
     item->itemRgb = color;
     item->description = cloneString(description);
     item->ref = cloneString(ref);
     item->alt = cloneString(alt);
     item->db = database;
     item->project = cloneString(project);
     item->mouseover = cloneString(mouseover);
     }
 
 if (!item)
     return;
 
+/* Parse blocks from JSON if present. Empty / missing means single full-span
+ * block; ensureItemBlocks synthesizes that below. */
+struct jsonElement *blockSizesJson = jsonFindNamedField(json, "jsonData", "blockSizes");
+struct jsonElement *chromStartsJson = jsonFindNamedField(json, "jsonData", "chromStarts");
+if (blockSizesJson && blockSizesJson->type == jsonList &&
+    chromStartsJson && chromStartsJson->type == jsonList)
+    {
+    int sizeN = slCount(blockSizesJson->val.jeList);
+    int startN = slCount(chromStartsJson->val.jeList);
+    if (sizeN != startN)
+        errAbort("blockSizes and chromStarts lengths differ (%d vs %d)", sizeN, startN);
+    if (sizeN > 0)
+        {
+        item->blockCount = sizeN;
+        AllocArray(item->blockSizes, sizeN);
+        AllocArray(item->chromStarts, sizeN);
+        struct slRef *el;
+        int i = 0;
+        for (el = blockSizesJson->val.jeList; el != NULL; el = el->next, i++)
+            {
+            struct jsonElement *child = (struct jsonElement *)el->val;
+            if (child->type != jsonNumber)
+                errAbort("blockSizes[%d] is not a number", i);
+            item->blockSizes[i] = child->val.jeNumber;
+            }
+        i = 0;
+        for (el = chromStartsJson->val.jeList; el != NULL; el = el->next, i++)
+            {
+            struct jsonElement *child = (struct jsonElement *)el->val;
+            if (child->type != jsonNumber)
+                errAbort("chromStarts[%d] is not a number", i);
+            item->chromStarts[i] = child->val.jeNumber;
+            }
+        }
+    }
+ensureItemBlocks(item);
+validateItemBlocks(item);
+
 /* Parse custom fields from JSON payload and ALTER TABLE as needed */
 char *tableName = myVariantsCreateTable(userName);
 struct sqlConnection *conn = hAllocConn(CUSTOM_TRASH);
 
 struct jsonElement *extraFieldsJson = jsonFindNamedField(json, "jsonData", "extraFields");
 if (extraFieldsJson && extraFieldsJson->type == jsonList)
     {
     struct slRef *el;
     for (el = extraFieldsJson->val.jeList; el != NULL; el = el->next)
         {
         struct jsonElement *cfObj = el->val;
         char *cfName = jsonOptionalStringField(cfObj, "name", NULL);
         char *cfValue = jsonOptionalStringField(cfObj, "value", "");
         if (isEmpty(cfName) || !isValidFieldName(cfName))
             continue;
@@ -462,30 +567,121 @@
         cartRemove(cart, varName);
         }
     return;
     }
 struct dyString *sql = sqlDyStringCreate("update %s set %s='%s' where id=%d",
     tableName, fieldName, newVal, id);
 if (isNotEmpty(scopeDb))
     sqlDyStringPrintf(sql, " and db='%s'", scopeDb);
 if (isNotEmpty(scopeProject) && !sameString(scopeProject, "*"))
     sqlDyStringPrintf(sql, " and project='%s'", scopeProject);
 sqlUpdate(conn, sql->string);
 dyStringFree(&sql);
 cartRemove(cart, varName);
 }
 
+static void updateBlocksFields(char *trackName, struct sqlConnection *conn,
+        char *tableName, int id, char *scopeProject, char *scopeDb)
+/* Pull <trackName>_blockCount, _blockSizes, _chromStarts from the cart,
+ * validate jointly against the row's current chromStart/chromEnd via
+ * loadAndValidateBed, and UPDATE all three columns in one statement.
+ * No-op if any of the three cart vars are missing. */
+{
+char vC[128], vS[128], vT[128];
+safef(vC, sizeof vC, "%s_blockCount", trackName);
+safef(vS, sizeof vS, "%s_blockSizes", trackName);
+safef(vT, sizeof vT, "%s_chromStarts", trackName);
+char *cartCount = cartOptionalString(cart, vC);
+char *cartSizes = cartOptionalString(cart, vS);
+char *cartStarts = cartOptionalString(cart, vT);
+if (cartCount == NULL || cartSizes == NULL || cartStarts == NULL)
+    return;
+/* Clone the cart strings before removing the cart vars: if validation
+ * errAborts below, the cart vars must not survive to corrupt the next
+ * edit on a different row. */
+char *blockCountStr = cloneString(cartCount);
+char *blockSizesStr = cloneString(cartSizes);
+char *chromStartsStr = cloneString(cartStarts);
+cartRemove(cart, vC);
+cartRemove(cart, vS);
+cartRemove(cart, vT);
+
+struct dyString *q = sqlDyStringCreate(
+    "select chrom,chromStart,chromEnd,name,score,strand,thickStart,thickEnd,itemRgb"
+    " from %s where id=%d", tableName, id);
+if (isNotEmpty(scopeDb))
+    sqlDyStringPrintf(q, " and db='%s'", scopeDb);
+if (isNotEmpty(scopeProject) && !sameString(scopeProject, "*"))
+    sqlDyStringPrintf(q, " and project='%s'", scopeProject);
+struct sqlResult *sr = sqlGetResult(conn, q->string);
+dyStringFree(&q);
+char **row = sqlNextRow(sr);
+if (row == NULL)
+    {
+    sqlFreeResult(&sr);
+    freeMem(blockCountStr);
+    freeMem(blockSizesStr);
+    freeMem(chromStartsStr);
+    return;
+    }
+struct myVariants item;
+ZeroVar(&item);
+item.chrom = cloneString(row[0]);
+item.chromStart = sqlUnsigned(row[1]);
+item.chromEnd = sqlUnsigned(row[2]);
+item.name = cloneString(row[3]);
+item.score = sqlUnsigned(row[4]);
+safecpy(item.strand, sizeof(item.strand), row[5]);
+item.thickStart = sqlUnsigned(row[6]);
+item.thickEnd = sqlUnsigned(row[7]);
+item.itemRgb = sqlUnsigned(row[8]);
+sqlFreeResult(&sr);
+
+item.blockCount = sqlUnsigned(blockCountStr);
+char *sizesDup = cloneString(blockSizesStr);
+char *startsDup = cloneString(chromStartsStr);
+int n;
+sqlSignedDynamicArray(sizesDup, &item.blockSizes, &n);
+if (n != item.blockCount)
+    errAbort("blockSizes count %d != blockCount %u", n, item.blockCount);
+sqlSignedDynamicArray(startsDup, &item.chromStarts, &n);
+if (n != item.blockCount)
+    errAbort("chromStarts count %d != blockCount %u", n, item.blockCount);
+freeMem(sizesDup);
+freeMem(startsDup);
+
+validateItemBlocks(&item);
+
+struct dyString *upd = sqlDyStringCreate(
+    "update %s set blockCount=%u,blockSizes='%s',chromStarts='%s' where id=%d",
+    tableName, item.blockCount, blockSizesStr, chromStartsStr, id);
+if (isNotEmpty(scopeDb))
+    sqlDyStringPrintf(upd, " and db='%s'", scopeDb);
+if (isNotEmpty(scopeProject) && !sameString(scopeProject, "*"))
+    sqlDyStringPrintf(upd, " and project='%s'", scopeProject);
+sqlUpdate(conn, upd->string);
+dyStringFree(&upd);
+
+freeMem(item.chrom);
+freeMem(item.name);
+freeMem(item.blockSizes);
+freeMem(item.chromStarts);
+freeMem(blockCountStr);
+freeMem(blockSizesStr);
+freeMem(chromStartsStr);
+}
+
 static void myVariantsEditOrDelete(char *trackName, struct sqlConnection *conn, char *tableName)
 /* Troll through cart variables looking for things that indicate user edited item
  * or deleted it in hgc,  and carry out edits. See hgc/myVariantsClick.c. */
 {
 char varName[128];
 char sql[256];
 safef(varName, sizeof(varName), "%s_%s", trackName, "id");
 char *idString = cartOptionalString(cart, varName);
 if (idString != NULL)
     {
     int id = sqlUnsigned(idString);
     idString = NULL; // Will be no good after cartRemove
     cartRemove(cart, varName); // Remove so only do edits once.
 
     /* Handle cancel. */
@@ -513,30 +709,31 @@
                 char ctVar[256];
                 safef(ctVar, sizeof ctVar, CT_FILE_VAR_PREFIX "%s", database);
                 cartSetString(cart, ctVar, ctFile);
                 freeMem(ctFile);
                 }
             }
         return;
         }
 
     /* Handle edits. Owner edits their own table, no project/db scope filter. */
     updateTextField(trackName, conn, tableName, "name", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "description", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "itemRgb", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "chromStart", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "chromEnd", id, NULL, NULL);
+    updateBlocksFields(trackName, conn, tableName, id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "ref", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "alt", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "project", id, NULL, NULL);
     updateTextField(trackName, conn, tableName, "mouseover", id, NULL, NULL);
     /* Update any custom fields */
         {
         char *editUserName = getUserName();
         struct slName *customCols = myVariantsGetCustomFields(editUserName);
         struct slName *col;
         for (col = customCols; col != NULL; col = col->next)
             updateTextField(trackName, conn, tableName, col->name, id, NULL, NULL);
         slFreeList(&customCols);
         }
     /* Trigger CT refresh after edits */
     {
@@ -580,31 +777,39 @@
     AllocVar(bed);
     /* bed->name is the item identifier passed to hgc, which parses "id name"
      * to look up the row by primary key.  myVariantsName() strips the "id "
      * prefix for the displayed label. */
     char buf[64];
     safef(buf, sizeof(buf), "%u %s", item->id, item->name);
     bed->chrom = item->chrom;
     bed->chromStart = item->chromStart;
     bed->chromEnd = item->chromEnd;
     bed->name = cloneString(buf);
     bed->score = item->score;
     bed->strand[0] = item->strand[0];
     bed->thickStart = item->thickStart;
     bed->thickEnd = item->thickEnd;
     bed->itemRgb = item->itemRgb;
-    lf = bedMungToLinkedFeatures(&bed, tdb, 9, 0, 1000, TRUE);
+    bed->blockCount = item->blockCount;
+    if (item->blockCount > 0)
+        {
+        bed->blockSizes = cloneMem(item->blockSizes,
+                                   item->blockCount * sizeof(int));
+        bed->chromStarts = cloneMem(item->chromStarts,
+                                    item->blockCount * sizeof(int));
+        }
+    lf = bedMungToLinkedFeatures(&bed, tdb, 12, 0, 1000, TRUE);
     dyStringClear(mouseover);
     if (item->mouseover && isNotEmpty(item->mouseover))
         lf->mouseOver = cloneString(item->mouseover);
     else
         {
         dyStringPrintf(mouseover, "%s", item->name);
         if (item->ref != NULL && isNotEmpty(item->ref))
             dyStringPrintf(mouseover, "<br>Ref: %s", item->ref);
         if (item->alt != NULL && isNotEmpty(item->alt))
             dyStringPrintf(mouseover, "<br>Alt: %s", item->alt);
         lf->mouseOver = cloneString(dyStringContents(mouseover));
         }
     slAddHead(&lfList, lf);
     }
 sqlFreeResult(&sr);
@@ -1019,30 +1224,31 @@
         cartRemove(cart, varName);
         myVariantsShareFree(&liveShare);
         continue;
         }
 
     /* Apply field edits, scoped to the share's project/db so a recipient
      * cannot edit rows in projects/assemblies they were not granted. */
     char *sp = liveShare->project;
     char *sd = liveShare->db;
     struct sqlConnection *conn = hAllocConn(CUSTOM_TRASH);
     updateTextField(trackName, conn, tableName, "name", id, sp, sd);
     updateTextField(trackName, conn, tableName, "description", id, sp, sd);
     updateTextField(trackName, conn, tableName, "itemRgb", id, sp, sd);
     updateTextField(trackName, conn, tableName, "chromStart", id, sp, sd);
     updateTextField(trackName, conn, tableName, "chromEnd", id, sp, sd);
+    updateBlocksFields(trackName, conn, tableName, id, sp, sd);
     updateTextField(trackName, conn, tableName, "ref", id, sp, sd);
     updateTextField(trackName, conn, tableName, "alt", id, sp, sd);
     updateTextField(trackName, conn, tableName, "mouseover", id, sp, sd);
     struct slName *customCols = myVariantsGetCustomFields(liveShare->ownerUser);
     struct slName *col;
     for (col = customCols; col != NULL; col = col->next)
         updateTextField(trackName, conn, tableName, col->name, id, sp, sd);
     slFreeList(&customCols);
     hFreeConn(&conn);
     myVariantsShareFree(&liveShare);
     }
 hashElFreeList(&shareVars);
 }
 
 boolean myVariantsTrackEnabled()