| | @@ -71,23 +71,29 @@ |
| 71 | 71 | ** There is no hyperlink on the file element of the path. |
| 72 | 72 | ** |
| 73 | 73 | ** The computed string is appended to the pOut blob. pOut should |
| 74 | 74 | ** have already been initialized. |
| 75 | 75 | */ |
| 76 | | -void hyperlinked_path(const char *zPath, Blob *pOut, const char *zCI){ |
| 76 | +void hyperlinked_path( |
| 77 | + const char *zPath, /* Path to render */ |
| 78 | + Blob *pOut, /* Write into this blob */ |
| 79 | + const char *zCI, /* check-in name, or NULL */ |
| 80 | + const char *zURI, /* "dir" or "tree" */ |
| 81 | + const char *zREx /* Extra query parameters */ |
| 82 | +){ |
| 77 | 83 | int i, j; |
| 78 | 84 | char *zSep = ""; |
| 79 | 85 | |
| 80 | 86 | for(i=0; zPath[i]; i=j){ |
| 81 | 87 | for(j=i; zPath[j] && zPath[j]!='/'; j++){} |
| 82 | 88 | if( zPath[j] && g.perm.Hyperlink ){ |
| 83 | 89 | if( zCI ){ |
| 84 | | - char *zLink = href("%R/dir?ci=%S&name=%#T", zCI, j, zPath); |
| 90 | + char *zLink = href("%R/%s?ci=%S&name=%#T%s", zURI, zCI, j, zPath,zREx); |
| 85 | 91 | blob_appendf(pOut, "%s%z%#h</a>", |
| 86 | 92 | zSep, zLink, j-i, &zPath[i]); |
| 87 | 93 | }else{ |
| 88 | | - char *zLink = href("%R/dir?name=%#T", j, zPath); |
| 94 | + char *zLink = href("%R/%s?name=%#T%s", zURI, j, zPath, zREx); |
| 89 | 95 | blob_appendf(pOut, "%s%z%#h</a>", |
| 90 | 96 | zSep, zLink, j-i, &zPath[i]); |
| 91 | 97 | } |
| 92 | 98 | }else{ |
| 93 | 99 | blob_appendf(pOut, "%s%#h", zSep, j-i, &zPath[i]); |
| | @@ -101,11 +107,11 @@ |
| 101 | 107 | /* |
| 102 | 108 | ** WEBPAGE: dir |
| 103 | 109 | ** |
| 104 | 110 | ** Query parameters: |
| 105 | 111 | ** |
| 106 | | -** name=PATH Directory to display. Required. |
| 112 | +** name=PATH Directory to display. Optional. Top-level if missing |
| 107 | 113 | ** ci=LABEL Show only files in this check-in. Optional. |
| 108 | 114 | */ |
| 109 | 115 | void page_dir(void){ |
| 110 | 116 | char *zD = fossil_strdup(P("name")); |
| 111 | 117 | int nD = zD ? strlen(zD)+1 : 0; |
| | @@ -118,18 +124,22 @@ |
| 118 | 124 | int rid = 0; |
| 119 | 125 | char *zUuid = 0; |
| 120 | 126 | Blob dirname; |
| 121 | 127 | Manifest *pM = 0; |
| 122 | 128 | const char *zSubdirLink; |
| 123 | | - int linkTrunk = 1, linkTip = 1; |
| 129 | + int linkTrunk = 1; |
| 130 | + int linkTip = 1; |
| 131 | + HQuery sURI; |
| 124 | 132 | |
| 133 | + if( strcmp(PD("type",""),"tree")==0 ){ page_tree(); return; } |
| 125 | 134 | login_check_credentials(); |
| 126 | 135 | if( !g.perm.Read ){ login_needed(); return; } |
| 127 | 136 | while( nD>1 && zD[nD-2]=='/' ){ zD[(--nD)-1] = 0; } |
| 128 | 137 | style_header("File List"); |
| 129 | 138 | sqlite3_create_function(g.db, "pathelement", 2, SQLITE_UTF8, 0, |
| 130 | 139 | pathelementFunc, 0, 0); |
| 140 | + url_initialize(&sURI, "dir"); |
| 131 | 141 | |
| 132 | 142 | /* If the name= parameter is an empty string, make it a NULL pointer */ |
| 133 | 143 | if( zD && strlen(zD)==0 ){ zD = 0; } |
| 134 | 144 | |
| 135 | 145 | /* If a specific check-in is requested, fetch and parse it. If the |
| | @@ -141,58 +151,57 @@ |
| 141 | 151 | if( pM ){ |
| 142 | 152 | int trunkRid = symbolic_name_to_rid("tag:trunk", "ci"); |
| 143 | 153 | linkTrunk = trunkRid && rid != trunkRid; |
| 144 | 154 | linkTip = rid != symbolic_name_to_rid("tip", "ci"); |
| 145 | 155 | zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid); |
| 156 | + url_add_parameter(&sURI, "ci", zCI); |
| 146 | 157 | }else{ |
| 147 | 158 | zCI = 0; |
| 148 | 159 | } |
| 149 | 160 | } |
| 150 | 161 | |
| 151 | 162 | /* Compute the title of the page */ |
| 152 | 163 | blob_zero(&dirname); |
| 153 | 164 | if( zD ){ |
| 165 | + url_add_parameter(&sURI, "name", zD); |
| 154 | 166 | blob_append(&dirname, "in directory ", -1); |
| 155 | | - hyperlinked_path(zD, &dirname, zCI); |
| 167 | + hyperlinked_path(zD, &dirname, zCI, "dir", ""); |
| 156 | 168 | zPrefix = mprintf("%s/", zD); |
| 157 | | - if( linkTrunk ){ |
| 158 | | - style_submenu_element("Trunk", "Trunk", "%R/dir?name=%t&ci=trunk", |
| 159 | | - zD); |
| 160 | | - } |
| 161 | | - if ( linkTip ){ |
| 162 | | - style_submenu_element("Tip", "Tip", "%R/dir?name=%t&ci=tip", zD); |
| 163 | | - } |
| 169 | + style_submenu_element("Top-Level", "Top-Level", "%s", |
| 170 | + url_render(&sURI, "name", 0, 0, 0)); |
| 164 | 171 | }else{ |
| 165 | 172 | blob_append(&dirname, "in the top-level directory", -1); |
| 166 | 173 | zPrefix = ""; |
| 167 | | - if( linkTrunk ){ |
| 168 | | - style_submenu_element("Trunk", "Trunk", "%R/dir?ci=trunk"); |
| 169 | | - } |
| 170 | | - if ( linkTip ){ |
| 171 | | - style_submenu_element("Tip", "Tip", "%R/dir?ci=tip"); |
| 172 | | - } |
| 174 | + } |
| 175 | + if( linkTrunk ){ |
| 176 | + style_submenu_element("Trunk", "Trunk", "%s", |
| 177 | + url_render(&sURI, "ci", "trunk", 0, 0)); |
| 178 | + } |
| 179 | + if( linkTip ){ |
| 180 | + style_submenu_element("Tip", "Tip", "%s", |
| 181 | + url_render(&sURI, "ci", "tip", 0, 0)); |
| 173 | 182 | } |
| 174 | 183 | if( zCI ){ |
| 175 | 184 | char zShort[20]; |
| 176 | 185 | memcpy(zShort, zUuid, 10); |
| 177 | 186 | zShort[10] = 0; |
| 178 | 187 | @ <h2>Files of check-in [%z(href("vinfo?name=%T",zUuid))%s(zShort)</a>] |
| 179 | 188 | @ %s(blob_str(&dirname))</h2> |
| 180 | 189 | zSubdirLink = mprintf("%R/dir?ci=%S&name=%T", zUuid, zPrefix); |
| 181 | | - if( zD ){ |
| 182 | | - style_submenu_element("Top", "Top", "%R/dir?ci=%S", zUuid); |
| 183 | | - style_submenu_element("All", "All", "%R/dir?name=%t", zD); |
| 184 | | - }else{ |
| 185 | | - style_submenu_element("All", "All", "%R/dir"); |
| 190 | + if( nD==0 ){ |
| 186 | 191 | style_submenu_element("File Ages", "File Ages", "%R/fileage?name=%S", |
| 187 | 192 | zUuid); |
| 188 | 193 | } |
| 189 | 194 | }else{ |
| 190 | 195 | @ <h2>The union of all files from all check-ins |
| 191 | 196 | @ %s(blob_str(&dirname))</h2> |
| 192 | 197 | zSubdirLink = mprintf("%R/dir?name=%T", zPrefix); |
| 193 | 198 | } |
| 199 | + style_submenu_element("All", "All", "%s", |
| 200 | + url_render(&sURI, "ci", 0, 0, 0)); |
| 201 | + style_submenu_element("Tree-View", "Tree-View", "%s", |
| 202 | + url_render(&sURI, "type", "tree", 0, 0)); |
| 194 | 203 | |
| 195 | 204 | /* Compute the temporary table "localfiles" containing the names |
| 196 | 205 | ** of all files and subdirectories in the zD[] directory. |
| 197 | 206 | ** |
| 198 | 207 | ** Subdirectory names begin with "/". This causes them to sort |
| | @@ -287,10 +296,309 @@ |
| 287 | 296 | db_finalize(&q); |
| 288 | 297 | manifest_destroy(pM); |
| 289 | 298 | @ </ul></td></tr></table> |
| 290 | 299 | style_footer(); |
| 291 | 300 | } |
| 301 | + |
| 302 | +/* |
| 303 | +** Objects used by the "tree" webpage. |
| 304 | +*/ |
| 305 | +typedef struct FileTreeNode FileTreeNode; |
| 306 | +typedef struct FileTree FileTree; |
| 307 | + |
| 308 | +/* |
| 309 | +** A single line of the file hierarchy |
| 310 | +*/ |
| 311 | +struct FileTreeNode { |
| 312 | + FileTreeNode *pNext; /* Next line in sequence */ |
| 313 | + FileTreeNode *pPrev; /* Previous line */ |
| 314 | + FileTreeNode *pParent; /* Directory containing this line */ |
| 315 | + char *zName; /* Name of this entry. The "tail" */ |
| 316 | + char *zFullName; /* Full pathname of this entry */ |
| 317 | + char *zUuid; /* SHA1 hash of this file. May be NULL. */ |
| 318 | + unsigned nFullName; /* Length of zFullName */ |
| 319 | + unsigned iLevel; /* Levels of parent directories */ |
| 320 | + u8 isDir; /* True if there are children */ |
| 321 | + u8 isLast; /* True if this is the last child of its parent */ |
| 322 | +}; |
| 323 | + |
| 324 | +/* |
| 325 | +** A complete file hierarchy |
| 326 | +*/ |
| 327 | +struct FileTree { |
| 328 | + FileTreeNode *pFirst; /* First line of the list */ |
| 329 | + FileTreeNode *pLast; /* Last line of the list */ |
| 330 | +}; |
| 331 | + |
| 332 | +/* |
| 333 | +** Add one or more new FileTreeNodes to the FileTree object so that the |
| 334 | +** leaf object zPathname is at the end of the node list |
| 335 | +*/ |
| 336 | +static void tree_add_node( |
| 337 | + FileTree *pTree, /* Tree into which nodes are added */ |
| 338 | + const char *zPath, /* The full pathname of file to add */ |
| 339 | + const char *zUuid /* UUID of the file. Might be NULL. */ |
| 340 | +){ |
| 341 | + int i; |
| 342 | + FileTreeNode *pParent; |
| 343 | + FileTreeNode *pChild; |
| 344 | + |
| 345 | + pChild = pTree->pLast; |
| 346 | + pParent = pChild ? pChild->pParent : 0; |
| 347 | + while( pParent!=0 && |
| 348 | + ( strncmp(pParent->zFullName, zPath, pParent->nFullName)!=0 |
| 349 | + || zPath[pParent->nFullName]!='/' ) |
| 350 | + ){ |
| 351 | + pChild = pParent; |
| 352 | + pParent = pChild->pParent; |
| 353 | + } |
| 354 | + i = pParent ? pParent->nFullName+1 : 0; |
| 355 | + if( pChild ) pChild->isLast = 0; |
| 356 | + while( zPath[i] ){ |
| 357 | + FileTreeNode *pNew; |
| 358 | + int iStart = i; |
| 359 | + int nByte; |
| 360 | + while( zPath[i] && zPath[i]!='/' ){ i++; } |
| 361 | + nByte = sizeof(*pNew) + i + 1; |
| 362 | + if( zUuid!=0 && zPath[i]==0 ) nByte += UUID_SIZE+1; |
| 363 | + pNew = fossil_malloc( nByte ); |
| 364 | + pNew->zFullName = (char*)&pNew[1]; |
| 365 | + memcpy(pNew->zFullName, zPath, i); |
| 366 | + pNew->zFullName[i] = 0; |
| 367 | + pNew->nFullName = i; |
| 368 | + if( zUuid!=0 && zPath[i]==0 ){ |
| 369 | + pNew->zUuid = pNew->zFullName + i + 1; |
| 370 | + memcpy(pNew->zUuid, zUuid, UUID_SIZE+1); |
| 371 | + }else{ |
| 372 | + pNew->zUuid = 0; |
| 373 | + } |
| 374 | + pNew->zName = pNew->zFullName + iStart; |
| 375 | + if( pTree->pLast ){ |
| 376 | + pTree->pLast->pNext = pNew; |
| 377 | + }else{ |
| 378 | + pTree->pFirst = pNew; |
| 379 | + } |
| 380 | + pNew->pPrev = pTree->pLast; |
| 381 | + pNew->pNext = 0; |
| 382 | + pNew->pParent = pParent; |
| 383 | + pTree->pLast = pNew; |
| 384 | + pNew->iLevel = pParent ? pParent->iLevel+1 : 0; |
| 385 | + pNew->isDir = zPath[i]=='/'; |
| 386 | + pNew->isLast = 1; |
| 387 | + while( zPath[i]=='/' ){ i++; } |
| 388 | + pParent = pNew; |
| 389 | + } |
| 390 | +} |
| 391 | + |
| 392 | +/* |
| 393 | +** Render parent lines for pNode |
| 394 | +*/ |
| 395 | +static void tree_indentation(FileTreeNode *p){ |
| 396 | + if( p==0 ) return; |
| 397 | + tree_indentation(p->pParent); |
| 398 | + if( p->isLast ){ |
| 399 | + cgi_append_content(" ", 4); |
| 400 | + }else{ |
| 401 | + cgi_append_content("│ ", 11); |
| 402 | + } |
| 403 | +} |
| 404 | + |
| 405 | + |
| 406 | +/* |
| 407 | +** WEBPAGE: tree |
| 408 | +** |
| 409 | +** Query parameters: |
| 410 | +** |
| 411 | +** name=PATH Directory to display. Optional |
| 412 | +** ci=LABEL Show only files in this check-in. Optional. |
| 413 | +** re=REGEXP Show only files matching REGEXP. Optional. |
| 414 | +*/ |
| 415 | +void page_tree(void){ |
| 416 | + char *zD = fossil_strdup(P("name")); |
| 417 | + int nD = zD ? strlen(zD)+1 : 0; |
| 418 | + const char *zCI = P("ci"); |
| 419 | + int rid = 0; |
| 420 | + char *zUuid = 0; |
| 421 | + Blob dirname; |
| 422 | + Manifest *pM = 0; |
| 423 | + int nFile = 0; /* Number of files */ |
| 424 | + int linkTrunk = 1; /* include link to "trunk" */ |
| 425 | + int linkTip = 1; /* include link to "tip" */ |
| 426 | + const char *zRE; /* the value for the re=REGEXP query parameter */ |
| 427 | + char *zPrefix; /* Prefix on all filenames */ |
| 428 | + char *zREx = ""; /* Extra parameters for path hyperlinks */ |
| 429 | + ReCompiled *pRE = 0; /* Compiled regular expression */ |
| 430 | + FileTreeNode *p; /* One line of the tree */ |
| 431 | + FileTree sTree; /* The complete tree of files */ |
| 432 | + HQuery sURI; /* Hyperlink */ |
| 433 | + |
| 434 | + if( strcmp(PD("type",""),"flat")==0 ){ page_dir(); return; } |
| 435 | + memset(&sTree, 0, sizeof(sTree)); |
| 436 | + login_check_credentials(); |
| 437 | + if( !g.perm.Read ){ login_needed(); return; } |
| 438 | + while( nD>1 && zD[nD-2]=='/' ){ zD[(--nD)-1] = 0; } |
| 439 | + style_header("File List"); |
| 440 | + sqlite3_create_function(g.db, "pathelement", 2, SQLITE_UTF8, 0, |
| 441 | + pathelementFunc, 0, 0); |
| 442 | + url_initialize(&sURI, "tree"); |
| 443 | + |
| 444 | + /* If a regular expression is specified, compile it */ |
| 445 | + zRE = P("re"); |
| 446 | + if( zRE ){ |
| 447 | + re_compile(&pRE, zRE, 0); |
| 448 | + url_add_parameter(&sURI, "re", zRE); |
| 449 | + zREx = mprintf("&re=%T", zRE); |
| 450 | + } |
| 451 | + |
| 452 | + /* If the name= parameter is an empty string, make it a NULL pointer */ |
| 453 | + if( zD && strlen(zD)==0 ){ zD = 0; } |
| 454 | + |
| 455 | + /* If a specific check-in is requested, fetch and parse it. If the |
| 456 | + ** specific check-in does not exist, clear zCI. zCI==0 will cause all |
| 457 | + ** files from all check-ins to be displayed. |
| 458 | + */ |
| 459 | + if( zCI ){ |
| 460 | + pM = manifest_get_by_name(zCI, &rid); |
| 461 | + if( pM ){ |
| 462 | + int trunkRid = symbolic_name_to_rid("tag:trunk", "ci"); |
| 463 | + linkTrunk = trunkRid && rid != trunkRid; |
| 464 | + linkTip = rid != symbolic_name_to_rid("tip", "ci"); |
| 465 | + zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid); |
| 466 | + url_add_parameter(&sURI, "ci", zCI); |
| 467 | + }else{ |
| 468 | + zCI = 0; |
| 469 | + } |
| 470 | + } |
| 471 | + |
| 472 | + /* Compute the title of the page */ |
| 473 | + blob_zero(&dirname); |
| 474 | + if( zD ){ |
| 475 | + url_add_parameter(&sURI, "name", zD); |
| 476 | + blob_append(&dirname, "within directory ", -1); |
| 477 | + hyperlinked_path(zD, &dirname, zCI, "tree", zREx); |
| 478 | + if( zRE ) blob_appendf(&dirname, " matching \"%s\"", zRE); |
| 479 | + zPrefix = mprintf("%T/", zD); |
| 480 | + style_submenu_element("Top-Level", "Top-Level", "%s", |
| 481 | + url_render(&sURI, "name", 0, 0, 0)); |
| 482 | + }else{ |
| 483 | + if( zRE ){ |
| 484 | + blob_appendf(&dirname, "matching \"%s\"", zRE); |
| 485 | + } |
| 486 | + zPrefix = ""; |
| 487 | + } |
| 488 | + if( zCI ){ |
| 489 | + style_submenu_element("All", "All", "%s", |
| 490 | + url_render(&sURI, "ci", 0, 0, 0)); |
| 491 | + } |
| 492 | + if( linkTrunk ){ |
| 493 | + style_submenu_element("Trunk", "Trunk", "%s", |
| 494 | + url_render(&sURI, "ci", "trunk", 0, 0)); |
| 495 | + } |
| 496 | + if ( linkTip ){ |
| 497 | + style_submenu_element("Tip", "Tip", "%s", |
| 498 | + url_render(&sURI, "ci", "tip", 0, 0)); |
| 499 | + } |
| 500 | + style_submenu_element("Flat-View", "Flat-View", "%s", |
| 501 | + url_render(&sURI, "type", "flat", 0, 0)); |
| 502 | + /* Compute the file hierarchy. |
| 503 | + */ |
| 504 | + if( zCI ){ |
| 505 | + Stmt ins, q; |
| 506 | + ManifestFile *pFile; |
| 507 | + |
| 508 | + db_multi_exec( |
| 509 | + "CREATE TEMP TABLE filelist(" |
| 510 | + " x TEXT PRIMARY KEY COLLATE nocase," |
| 511 | + " uuid TEXT" |
| 512 | + ") WITHOUT ROWID;" |
| 513 | + ); |
| 514 | + db_prepare(&ins, "INSERT OR IGNORE INTO filelist VALUES(:f,:u)"); |
| 515 | + manifest_file_rewind(pM); |
| 516 | + while( (pFile = manifest_file_next(pM,0))!=0 ){ |
| 517 | + if( nD>0 |
| 518 | + && (fossil_strncmp(pFile->zName, zD, nD-1)!=0 |
| 519 | + || pFile->zName[nD-1]!='/') |
| 520 | + ){ |
| 521 | + continue; |
| 522 | + } |
| 523 | + if( pRE && re_match(pRE, (const u8*)pFile->zName, -1)==0 ) continue; |
| 524 | + db_bind_text(&ins, ":f", &pFile->zName[nD]); |
| 525 | + db_bind_text(&ins, ":u", pFile->zUuid); |
| 526 | + db_step(&ins); |
| 527 | + db_reset(&ins); |
| 528 | + } |
| 529 | + db_finalize(&ins); |
| 530 | + db_prepare(&q, "SELECT x, uuid FROM filelist ORDER BY x"); |
| 531 | + while( db_step(&q)==SQLITE_ROW ){ |
| 532 | + tree_add_node(&sTree, db_column_text(&q,0), db_column_text(&q,1)); |
| 533 | + nFile++; |
| 534 | + } |
| 535 | + db_finalize(&q); |
| 536 | + }else{ |
| 537 | + Stmt q; |
| 538 | + db_prepare(&q, "SELECT name FROM filename ORDER BY name COLLATE nocase"); |
| 539 | + while( db_step(&q)==SQLITE_ROW ){ |
| 540 | + const char *z = db_column_text(&q, 0); |
| 541 | + if( nD>0 && (fossil_strncmp(z, zD, nD-1)!=0 || z[nD-1]!='/') ){ |
| 542 | + continue; |
| 543 | + } |
| 544 | + if( pRE && re_match(pRE, (const u8*)z, -1)==0 ) continue; |
| 545 | + tree_add_node(&sTree, z+nD, 0); |
| 546 | + nFile++; |
| 547 | + } |
| 548 | + db_finalize(&q); |
| 549 | + } |
| 550 | + |
| 551 | + if( zCI ){ |
| 552 | + @ <h2>%d(nFile) files of |
| 553 | + @ check-in [%z(href("vinfo?name=%T",zUuid))%S(zUuid)</a>] |
| 554 | + @ %s(blob_str(&dirname))</h2> |
| 555 | + }else{ |
| 556 | + int n = db_int(0, "SELECT count(*) FROM plink"); |
| 557 | + @ <h2>%d(nFile) files from all %d(n) check-ins |
| 558 | + @ %s(blob_str(&dirname))</h2> |
| 559 | + } |
| 560 | + |
| 561 | + |
| 562 | + /* Generate a multi-column table listing the contents of zD[] |
| 563 | + ** directory. |
| 564 | + */ |
| 565 | + @ <pre> |
| 566 | + if( nD ){ |
| 567 | + cgi_printf("%.*h\n", nD, zD); |
| 568 | + }else{ |
| 569 | + @ . |
| 570 | + } |
| 571 | + for(p=sTree.pFirst; p; p=p->pNext){ |
| 572 | + tree_indentation(p->pParent); |
| 573 | + if( p->isLast ){ |
| 574 | + cgi_append_content("└── ", 25); |
| 575 | + }else{ |
| 576 | + cgi_append_content("├── ", 25); |
| 577 | + } |
| 578 | + if( p->isDir ){ |
| 579 | + char *zName = mprintf("%s%T", zPrefix, p->zFullName); |
| 580 | + char *zLink = href("%s", url_render(&sURI, "name", zName, 0, 0)); |
| 581 | + fossil_free(zName); |
| 582 | + @ %z(zLink)%h(p->zName)</a> |
| 583 | + }else{ |
| 584 | + char *zLink; |
| 585 | + if( zCI ){ |
| 586 | + zLink = href("%R/artifact/%s",p->zUuid); |
| 587 | + }else{ |
| 588 | + zLink = href("%R/finfo?name=%s%T",zPrefix,p->zFullName); |
| 589 | + } |
| 590 | + @ %z(zLink)%h(p->zName)</a> |
| 591 | + } |
| 592 | + } |
| 593 | + @ </pre> |
| 594 | + style_footer(); |
| 595 | + |
| 596 | + /* We could free memory used by sTree here if we needed to. But |
| 597 | + ** the process is about to exit, so doing so would not really accomplish |
| 598 | + ** anything useful. */ |
| 599 | +} |
| 292 | 600 | |
| 293 | 601 | /* |
| 294 | 602 | ** Return a CSS class name based on the given filename's extension. |
| 295 | 603 | ** Result must be freed by the caller. |
| 296 | 604 | **/ |
| 297 | 605 | |