1 /*
2 ** Copyright (c) 2011 D. Richard Hipp
3 **
4 ** This program is free software; you can redistribute it and/or
5 ** modify it under the terms of the Simplified BSD License (also
6 ** known as the "2-Clause License" or "FreeBSD License".)
7
8 ** This program is distributed in the hope that it will be useful,
9 ** but without any warranty; without even the implied warranty of
10 ** merchantability or fitness for a particular purpose.
11 **
12 ** Author contact information:
13 ** drh@sqlite.org
14 **
15 *******************************************************************************
16 **
17 ** This file contains code used to trace paths of through the
18 ** directed acyclic graph (DAG) of check-ins.
19 */
20 #include "config.h"
21 #include "path.h"
22 #include <assert.h>
23
24 #if INTERFACE
25 /* Nodes for the paths through the DAG.
26 */
27 struct PathNode {
28 int rid; /* ID for this node */
29 u8 fromIsParent; /* True if pFrom is the parent of rid */
30 u8 isPrim; /* True if primary side of common ancestor */
31 u8 isHidden; /* Abbreviate output in "fossil bisect ls" */
32 PathNode *pFrom; /* Node we came from */
33 union {
34 PathNode *pPeer; /* List of nodes of the same generation */
35 PathNode *pTo; /* Next on path from beginning to end */
36 } u;
37 PathNode *pAll; /* List of all nodes */
38 };
39 #endif
40
41 /*
42 ** Local variables for this module
43 */
44 static struct {
45 PathNode *pCurrent; /* Current generation of nodes */
46 PathNode *pAll; /* All nodes */
47 Bag seen; /* Nodes seen before */
48 int nStep; /* Number of steps from first to last */
49 int nNotHidden; /* Number of steps not counting hidden nodes */
50 PathNode *pStart; /* Earliest node */
51 PathNode *pEnd; /* Most recent */
52 } path;
53
54 /*
55 ** Return the first (last) element of the computed path.
56 */
path_first(void)57 PathNode *path_first(void){ return path.pStart; }
path_last(void)58 PathNode *path_last(void){ return path.pEnd; }
59
60 /*
61 ** Return the number of steps in the computed path.
62 */
path_length(void)63 int path_length(void){ return path.nStep; }
64
65 /*
66 ** Return the number of non-hidden steps in the computed path.
67 */
path_length_not_hidden(void)68 int path_length_not_hidden(void){ return path.nNotHidden; }
69
70 /*
71 ** Create a new node
72 */
path_new_node(int rid,PathNode * pFrom,int isParent)73 static PathNode *path_new_node(int rid, PathNode *pFrom, int isParent){
74 PathNode *p;
75
76 p = fossil_malloc( sizeof(*p) );
77 memset(p, 0, sizeof(*p));
78 p->rid = rid;
79 p->fromIsParent = isParent;
80 p->pFrom = pFrom;
81 p->u.pPeer = path.pCurrent;
82 path.pCurrent = p;
83 p->pAll = path.pAll;
84 path.pAll = p;
85 bag_insert(&path.seen, rid);
86 return p;
87 }
88
89 /*
90 ** Reset memory used by the shortest path algorithm.
91 */
path_reset(void)92 void path_reset(void){
93 PathNode *p;
94 while( path.pAll ){
95 p = path.pAll;
96 path.pAll = p->pAll;
97 fossil_free(p);
98 }
99 bag_clear(&path.seen);
100 memset(&path, 0, sizeof(path));
101 }
102
103 /*
104 ** Construct the path from path.pStart to path.pEnd in the u.pTo fields.
105 */
path_reverse_path(void)106 static void path_reverse_path(void){
107 PathNode *p;
108 assert( path.pEnd!=0 );
109 for(p=path.pEnd; p && p->pFrom; p = p->pFrom){
110 p->pFrom->u.pTo = p;
111 }
112 path.pEnd->u.pTo = 0;
113 assert( p==path.pStart );
114 }
115
116 /*
117 ** Compute the shortest path from iFrom to iTo
118 **
119 ** If directOnly is true, then use only the "primary" links from parent to
120 ** child. In other words, ignore merges.
121 **
122 ** Return a pointer to the beginning of the path (the iFrom node).
123 ** Elements of the path can be traversed by following the PathNode.u.pTo
124 ** pointer chain.
125 **
126 ** Return NULL if no path is found.
127 */
path_shortest(int iFrom,int iTo,int directOnly,int oneWayOnly,Bag * pHidden)128 PathNode *path_shortest(
129 int iFrom, /* Path starts here */
130 int iTo, /* Path ends here */
131 int directOnly, /* No merge links if true */
132 int oneWayOnly, /* Parent->child only if true */
133 Bag *pHidden /* Hidden nodes */
134 ){
135 Stmt s;
136 PathNode *pPrev;
137 PathNode *p;
138
139 path_reset();
140 path.pStart = path_new_node(iFrom, 0, 0);
141 if( iTo==iFrom ){
142 path.pEnd = path.pStart;
143 return path.pStart;
144 }
145 if( oneWayOnly && directOnly ){
146 db_prepare(&s,
147 "SELECT cid, 1 FROM plink WHERE pid=:pid AND isprim"
148 );
149 }else if( oneWayOnly ){
150 db_prepare(&s,
151 "SELECT cid, 1 FROM plink WHERE pid=:pid "
152 );
153 }else if( directOnly ){
154 db_prepare(&s,
155 "SELECT cid, 1 FROM plink WHERE pid=:pid AND isprim "
156 "UNION ALL "
157 "SELECT pid, 0 FROM plink WHERE cid=:pid AND isprim"
158 );
159 }else{
160 db_prepare(&s,
161 "SELECT cid, 1 FROM plink WHERE pid=:pid "
162 "UNION ALL "
163 "SELECT pid, 0 FROM plink WHERE cid=:pid"
164 );
165 }
166 while( path.pCurrent ){
167 path.nStep++;
168 pPrev = path.pCurrent;
169 path.pCurrent = 0;
170 while( pPrev ){
171 db_bind_int(&s, ":pid", pPrev->rid);
172 while( db_step(&s)==SQLITE_ROW ){
173 int cid = db_column_int(&s, 0);
174 int isParent = db_column_int(&s, 1);
175 if( bag_find(&path.seen, cid) ) continue;
176 p = path_new_node(cid, pPrev, isParent);
177 if( pHidden && bag_find(pHidden,cid) ) p->isHidden = 1;
178 if( cid==iTo ){
179 db_finalize(&s);
180 path.pEnd = p;
181 path_reverse_path();
182 for(p=path.pStart->u.pTo; p; p=p->u.pTo ){
183 if( !p->isHidden ) path.nNotHidden++;
184 }
185 return path.pStart;
186 }
187 }
188 db_reset(&s);
189 pPrev = pPrev->u.pPeer;
190 }
191 }
192 db_finalize(&s);
193 path_reset();
194 return 0;
195 }
196
197 /*
198 ** Find the mid-point of the path. If the path contains fewer than
199 ** 2 steps, return 0.
200 */
path_midpoint(void)201 PathNode *path_midpoint(void){
202 PathNode *p;
203 int i;
204 if( path.nNotHidden<2 ) return 0;
205 for(p=path.pEnd, i=0; p && (p->isHidden || i<path.nNotHidden/2); p=p->pFrom){
206 if( !p->isHidden ) i++;
207 }
208 return p;
209 }
210
211 /*
212 ** Return an estimate of the number of comparisons remaining in order
213 ** to bisect path. This is based on the log2() of path.nStep.
214 */
path_search_depth(void)215 int path_search_depth(void){
216 int i, j;
217 for(i=0, j=1; j<path.nNotHidden; i++, j+=j){}
218 return i;
219 }
220
221 /*
222 ** Compute the shortest path between two check-ins and then transfer
223 ** that path into the "ancestor" table. This is a utility used by
224 ** both /annotate and /finfo. See also: compute_direct_ancestors().
225 */
path_shortest_stored_in_ancestor_table(int origid,int cid)226 void path_shortest_stored_in_ancestor_table(
227 int origid, /* RID for check-in at start of the path */
228 int cid /* RID for check-in at the end of the path */
229 ){
230 PathNode *pPath;
231 int gen = 0;
232 Stmt ins;
233 pPath = path_shortest(cid, origid, 1, 0, 0);
234 db_multi_exec(
235 "CREATE TEMP TABLE IF NOT EXISTS ancestor("
236 " rid INT UNIQUE,"
237 " generation INTEGER PRIMARY KEY"
238 ");"
239 "DELETE FROM ancestor;"
240 );
241 db_prepare(&ins, "INSERT INTO ancestor(rid, generation) VALUES(:rid,:gen)");
242 while( pPath ){
243 db_bind_int(&ins, ":rid", pPath->rid);
244 db_bind_int(&ins, ":gen", ++gen);
245 db_step(&ins);
246 db_reset(&ins);
247 pPath = pPath->u.pTo;
248 }
249 db_finalize(&ins);
250 path_reset();
251 }
252
253 /*
254 ** COMMAND: test-shortest-path
255 **
256 ** Usage: %fossil test-shortest-path ?--no-merge? VERSION1 VERSION2
257 **
258 ** Report the shortest path between two check-ins. If the --no-merge flag
259 ** is used, follow only direct parent-child paths and omit merge links.
260 */
shortest_path_test_cmd(void)261 void shortest_path_test_cmd(void){
262 int iFrom;
263 int iTo;
264 PathNode *p;
265 int n;
266 int directOnly;
267 int oneWay;
268
269 db_find_and_open_repository(0,0);
270 directOnly = find_option("no-merge",0,0)!=0;
271 oneWay = find_option("one-way",0,0)!=0;
272 if( g.argc!=4 ) usage("VERSION1 VERSION2");
273 iFrom = name_to_rid(g.argv[2]);
274 iTo = name_to_rid(g.argv[3]);
275 p = path_shortest(iFrom, iTo, directOnly, oneWay, 0);
276 if( p==0 ){
277 fossil_fatal("no path from %s to %s", g.argv[1], g.argv[2]);
278 }
279 for(n=1, p=path.pStart; p; p=p->u.pTo, n++){
280 char *z;
281 z = db_text(0,
282 "SELECT substr(uuid,1,12) || ' ' || datetime(mtime)"
283 " FROM blob, event"
284 " WHERE blob.rid=%d AND event.objid=%d AND event.type='ci'",
285 p->rid, p->rid);
286 fossil_print("%4d: %5d %s", n, p->rid, z);
287 fossil_free(z);
288 if( p->u.pTo ){
289 fossil_print(" is a %s of\n",
290 p->u.pTo->fromIsParent ? "parent" : "child");
291 }else{
292 fossil_print("\n");
293 }
294 }
295 }
296
297 /*
298 ** Find the closest common ancestor of two nodes. "Closest" means the
299 ** fewest number of arcs.
300 */
path_common_ancestor(int iMe,int iYou)301 int path_common_ancestor(int iMe, int iYou){
302 Stmt s;
303 PathNode *pPrev;
304 PathNode *p;
305 Bag me, you;
306
307 if( iMe==iYou ) return iMe;
308 if( iMe==0 || iYou==0 ) return 0;
309 path_reset();
310 path.pStart = path_new_node(iMe, 0, 0);
311 path.pStart->isPrim = 1;
312 path.pEnd = path_new_node(iYou, 0, 0);
313 db_prepare(&s, "SELECT pid FROM plink WHERE cid=:cid");
314 bag_init(&me);
315 bag_insert(&me, iMe);
316 bag_init(&you);
317 bag_insert(&you, iYou);
318 while( path.pCurrent ){
319 pPrev = path.pCurrent;
320 path.pCurrent = 0;
321 while( pPrev ){
322 db_bind_int(&s, ":cid", pPrev->rid);
323 while( db_step(&s)==SQLITE_ROW ){
324 int pid = db_column_int(&s, 0);
325 if( bag_find(pPrev->isPrim ? &you : &me, pid) ){
326 /* pid is the common ancestor */
327 PathNode *pNext;
328 for(p=path.pAll; p && p->rid!=pid; p=p->pAll){}
329 assert( p!=0 );
330 pNext = p;
331 while( pNext ){
332 pNext = p->pFrom;
333 p->pFrom = pPrev;
334 pPrev = p;
335 p = pNext;
336 }
337 if( pPrev==path.pStart ) path.pStart = path.pEnd;
338 path.pEnd = pPrev;
339 path_reverse_path();
340 db_finalize(&s);
341 return pid;
342 }else if( bag_find(&path.seen, pid) ){
343 /* pid is just an alternative path on one of the legs */
344 continue;
345 }
346 p = path_new_node(pid, pPrev, 0);
347 p->isPrim = pPrev->isPrim;
348 bag_insert(pPrev->isPrim ? &me : &you, pid);
349 }
350 db_reset(&s);
351 pPrev = pPrev->u.pPeer;
352 }
353 }
354 db_finalize(&s);
355 path_reset();
356 return 0;
357 }
358
359 /*
360 ** COMMAND: test-ancestor-path
361 **
362 ** Usage: %fossil test-ancestor-path VERSION1 VERSION2
363 **
364 ** Report the path from VERSION1 to VERSION2 through their most recent
365 ** common ancestor.
366 */
ancestor_path_test_cmd(void)367 void ancestor_path_test_cmd(void){
368 int iFrom;
369 int iTo;
370 int iPivot;
371 PathNode *p;
372 int n;
373
374 db_find_and_open_repository(0,0);
375 if( g.argc!=4 ) usage("VERSION1 VERSION2");
376 iFrom = name_to_rid(g.argv[2]);
377 iTo = name_to_rid(g.argv[3]);
378 iPivot = path_common_ancestor(iFrom, iTo);
379 for(n=1, p=path.pStart; p; p=p->u.pTo, n++){
380 char *z;
381 z = db_text(0,
382 "SELECT substr(uuid,1,12) || ' ' || datetime(mtime)"
383 " FROM blob, event"
384 " WHERE blob.rid=%d AND event.objid=%d AND event.type='ci'",
385 p->rid, p->rid);
386 fossil_print("%4d: %5d %s", n, p->rid, z);
387 fossil_free(z);
388 if( p->rid==iFrom ) fossil_print(" VERSION1");
389 if( p->rid==iTo ) fossil_print(" VERSION2");
390 if( p->rid==iPivot ) fossil_print(" PIVOT");
391 fossil_print("\n");
392 }
393 }
394
395
396 /*
397 ** A record of a file rename operation.
398 */
399 typedef struct NameChange NameChange;
400 struct NameChange {
401 int origName; /* Original name of file */
402 int curName; /* Current name of the file */
403 int newName; /* Name of file in next version */
404 NameChange *pNext; /* List of all name changes */
405 };
406
407 /*
408 ** Compute all file name changes that occur going from check-in iFrom
409 ** to check-in iTo.
410 **
411 ** The number of name changes is written into *pnChng. For each name
412 ** change, two integers are allocated for *piChng. The first is the
413 ** filename.fnid for the original name as seen in check-in iFrom and
414 ** the second is for new name as it is used in check-in iTo.
415 **
416 ** Space to hold *piChng is obtained from fossil_malloc() and should
417 ** be released by the caller.
418 **
419 ** This routine really has nothing to do with path. It is located
420 ** in this path.c module in order to leverage some of the path
421 ** infrastructure.
422 */
find_filename_changes(int iFrom,int iTo,int revOK,int * pnChng,int ** aiChng,const char * zDebug)423 void find_filename_changes(
424 int iFrom, /* Ancestor check-in */
425 int iTo, /* Recent check-in */
426 int revOK, /* OK to move backwards (child->parent) if true */
427 int *pnChng, /* Number of name changes along the path */
428 int **aiChng, /* Name changes */
429 const char *zDebug /* Generate trace output if no NULL */
430 ){
431 PathNode *p; /* For looping over path from iFrom to iTo */
432 NameChange *pAll = 0; /* List of all name changes seen so far */
433 NameChange *pChng; /* For looping through the name change list */
434 int nChng = 0; /* Number of files whose names have changed */
435 int *aChng; /* Two integers per name change */
436 int i; /* Loop counter */
437 Stmt q1; /* Query of name changes */
438
439 *pnChng = 0;
440 *aiChng = 0;
441 if(0==iFrom){
442 fossil_fatal("Invalid 'from' RID: 0");
443 }else if(0==iTo){
444 fossil_fatal("Invalid 'to' RID: 0");
445 }
446 if( iFrom==iTo ) return;
447 path_reset();
448 p = path_shortest(iFrom, iTo, 1, revOK==0, 0);
449 if( p==0 ) return;
450 path_reverse_path();
451 db_prepare(&q1,
452 "SELECT pfnid, fnid FROM mlink"
453 " WHERE mid=:mid AND (pfnid>0 OR fid==0)"
454 " ORDER BY pfnid"
455 );
456 for(p=path.pStart; p; p=p->u.pTo){
457 int fnid, pfnid;
458 if( !p->fromIsParent && (p->u.pTo==0 || p->u.pTo->fromIsParent) ){
459 /* Skip nodes where the parent is not on the path */
460 continue;
461 }
462 db_bind_int(&q1, ":mid", p->rid);
463 while( db_step(&q1)==SQLITE_ROW ){
464 fnid = db_column_int(&q1, 1);
465 pfnid = db_column_int(&q1, 0);
466 if( pfnid==0 ){
467 pfnid = fnid;
468 fnid = 0;
469 }
470 if( !p->fromIsParent ){
471 int t = fnid;
472 fnid = pfnid;
473 pfnid = t;
474 }
475 if( zDebug ){
476 fossil_print("%s at %d%s %.10z: %d[%z] -> %d[%z]\n",
477 zDebug, p->rid, p->fromIsParent ? ">" : "<",
478 db_text(0, "SELECT uuid FROM blob WHERE rid=%d", p->rid),
479 pfnid,
480 db_text(0, "SELECT name FROM filename WHERE fnid=%d", pfnid),
481 fnid,
482 db_text(0, "SELECT name FROM filename WHERE fnid=%d", fnid));
483 }
484 for(pChng=pAll; pChng; pChng=pChng->pNext){
485 if( pChng->curName==pfnid ){
486 pChng->newName = fnid;
487 break;
488 }
489 }
490 if( pChng==0 && fnid>0 ){
491 pChng = fossil_malloc( sizeof(*pChng) );
492 pChng->pNext = pAll;
493 pAll = pChng;
494 pChng->origName = pfnid;
495 pChng->curName = pfnid;
496 pChng->newName = fnid;
497 nChng++;
498 }
499 }
500 for(pChng=pAll; pChng; pChng=pChng->pNext){
501 pChng->curName = pChng->newName;
502 }
503 db_reset(&q1);
504 }
505 db_finalize(&q1);
506 if( nChng ){
507 aChng = *aiChng = fossil_malloc( nChng*2*sizeof(int) );
508 for(pChng=pAll, i=0; pChng; pChng=pChng->pNext){
509 if( pChng->newName==0 ) continue;
510 if( pChng->origName==0 ) continue;
511 aChng[i] = pChng->origName;
512 aChng[i+1] = pChng->newName;
513 if( zDebug ){
514 fossil_print("%s summary %d[%z] -> %d[%z]\n",
515 zDebug,
516 aChng[i],
517 db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i]),
518 aChng[i+1],
519 db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i+1]));
520 }
521 i += 2;
522 }
523 *pnChng = i/2;
524 while( pAll ){
525 pChng = pAll;
526 pAll = pAll->pNext;
527 fossil_free(pChng);
528 }
529 }
530 }
531
532 /*
533 ** COMMAND: test-name-changes
534 **
535 ** Usage: %fossil test-name-changes [--debug] VERSION1 VERSION2
536 **
537 ** Show all filename changes that occur going from VERSION1 to VERSION2
538 */
test_name_change(void)539 void test_name_change(void){
540 int iFrom;
541 int iTo;
542 int *aChng;
543 int nChng;
544 int i;
545 const char *zDebug = 0;
546 int revOK = 0;
547
548 db_find_and_open_repository(0,0);
549 zDebug = find_option("debug",0,0)!=0 ? "debug" : 0;
550 revOK = find_option("bidirectional",0,0)!=0;
551 if( g.argc<4 ) usage("VERSION1 VERSION2");
552 while( g.argc>=4 ){
553 iFrom = name_to_rid(g.argv[2]);
554 iTo = name_to_rid(g.argv[3]);
555 find_filename_changes(iFrom, iTo, revOK, &nChng, &aChng, zDebug);
556 fossil_print("------ Changes for (%d) %s -> (%d) %s\n",
557 iFrom, g.argv[2], iTo, g.argv[3]);
558 for(i=0; i<nChng; i++){
559 char *zFrom, *zTo;
560
561 zFrom = db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i*2]);
562 zTo = db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i*2+1]);
563 fossil_print("[%s] -> [%s]\n", zFrom, zTo);
564 fossil_free(zFrom);
565 fossil_free(zTo);
566 }
567 fossil_free(aChng);
568 g.argv += 2;
569 g.argc -= 2;
570 }
571 }
572
573 /* Query to extract all rename operations */
574 static const char zRenameQuery[] =
575 @ CREATE TEMP TABLE renames AS
576 @ SELECT
577 @ datetime(event.mtime) AS date,
578 @ F.name AS old_name,
579 @ T.name AS new_name,
580 @ blob.uuid AS checkin
581 @ FROM mlink, filename F, filename T, event, blob
582 @ WHERE coalesce(mlink.pfnid,0)!=0 AND mlink.pfnid!=mlink.fnid
583 @ AND F.fnid=mlink.pfnid
584 @ AND T.fnid=mlink.fnid
585 @ AND event.objid=mlink.mid
586 @ AND event.type='ci'
587 @ AND blob.rid=mlink.mid;
588 ;
589
590 /* Query to extract distinct rename operations */
591 static const char zDistinctRenameQuery[] =
592 @ CREATE TEMP TABLE renames AS
593 @ SELECT
594 @ min(datetime(event.mtime)) AS date,
595 @ F.name AS old_name,
596 @ T.name AS new_name,
597 @ blob.uuid AS checkin
598 @ FROM mlink, filename F, filename T, event, blob
599 @ WHERE coalesce(mlink.pfnid,0)!=0 AND mlink.pfnid!=mlink.fnid
600 @ AND F.fnid=mlink.pfnid
601 @ AND T.fnid=mlink.fnid
602 @ AND event.objid=mlink.mid
603 @ AND event.type='ci'
604 @ AND blob.rid=mlink.mid
605 @ GROUP BY 2, 3;
606 ;
607
608 /*
609 ** WEBPAGE: test-rename-list
610 **
611 ** Print a list of all file rename operations throughout history.
612 ** This page is intended for testing purposes only and may change
613 ** or be discontinued without notice.
614 */
test_rename_list_page(void)615 void test_rename_list_page(void){
616 Stmt q;
617 int nRename;
618 int nCheckin;
619
620 login_check_credentials();
621 if( !g.perm.Read ){ login_needed(g.anon.Read); return; }
622 style_set_current_feature("test");
623 if( P("all")!=0 ){
624 style_header("List Of All Filename Changes");
625 db_multi_exec("%s", zRenameQuery/*safe-for-%s*/);
626 style_submenu_element("Distinct", "%R/test-rename-list");
627 }else{
628 style_header("List Of Distinct Filename Changes");
629 db_multi_exec("%s", zDistinctRenameQuery/*safe-for-%s*/);
630 style_submenu_element("All", "%R/test-rename-list?all");
631 }
632 nRename = db_int(0, "SELECT count(*) FROM renames;");
633 nCheckin = db_int(0, "SELECT count(DISTINCT checkin) FROM renames;");
634 db_prepare(&q, "SELECT date, old_name, new_name, checkin FROM renames"
635 " ORDER BY date DESC, old_name ASC");
636 @ <h1>%d(nRename) filename changes in %d(nCheckin) check-ins</h1>
637 @ <table class='sortable' data-column-types='tttt' data-init-sort='1'\
638 @ border="1" cellpadding="2" cellspacing="0">
639 @ <thead><tr><th>Date & Time</th>
640 @ <th>Old Name</th>
641 @ <th>New Name</th>
642 @ <th>Check-in</th></tr></thead><tbody>
643 while( db_step(&q)==SQLITE_ROW ){
644 const char *zDate = db_column_text(&q, 0);
645 const char *zOld = db_column_text(&q, 1);
646 const char *zNew = db_column_text(&q, 2);
647 const char *zUuid = db_column_text(&q, 3);
648 @ <tr>
649 @ <td>%z(href("%R/timeline?c=%t",zDate))%s(zDate)</a></td>
650 @ <td>%z(href("%R/finfo?name=%t",zOld))%h(zOld)</a></td>
651 @ <td>%z(href("%R/finfo?name=%t",zNew))%h(zNew)</a></td>
652 @ <td>%z(href("%R/info/%!S",zUuid))%S(zUuid)</a></td></tr>
653 }
654 @ </tbody></table>
655 db_finalize(&q);
656 style_table_sorter();
657 style_finish_page();
658 }
659