Fossil SCM

Merge all the latest trunk enhancements into the http1-1-chunked branch so that the branch can be more easily tested.

drh 2026-06-22 11:03 UTC http1-1-chunked merge
Commit 4a648d7410ad9d6f4baf10e6c14ea0d6e86708552d014fb386bb1c7bb411fa81
+112 -40
--- extsrc/shell.c
+++ extsrc/shell.c
@@ -5424,11 +5424,11 @@
54245424
}
54255425
j = 0;
54265426
}else{
54275427
i = 0;
54285428
}
5429
- (void)memcpy(&p->buffer[j], &data[i], len - i);
5429
+ if( len-i>0 ) (void)memcpy(&p->buffer[j], &data[i], len - i);
54305430
}
54315431
54325432
/* Compute a string using sqlite3_vsnprintf() and hash it */
54335433
static void hash_step_vformat(
54345434
SHA1Context *p, /* Add content to this context */
@@ -6071,32 +6071,41 @@
60716071
}
60726072
z[i] = 0;
60736073
sqlite3_result_text(pCtx, z, i, sqlite3_free);
60746074
}
60756075
6076
+/* Forward declaration */
6077
+static void decimal_expand(Decimal *p, int nDigit, int nFrac);
6078
+
60766079
/*
60776080
** Round a decimal value to N significant digits. N must be positive.
60786081
*/
60796082
static void decimal_round(Decimal *p, int N){
60806083
int i;
6081
- int nZero;
6084
+ int nZero; /* Number of leading zeros */
60826085
if( N<1 ) return;
60836086
if( p==0 ) return;
60846087
if( p->nDigit<=N ) return;
60856088
for(nZero=0; nZero<p->nDigit && p->a[nZero]==0; nZero++){}
60866089
N += nZero;
60876090
if( p->nDigit<=N ) return;
6088
- if( p->a[N]>4 ){
6091
+ if( p->a[N]>=5 ){
6092
+ /* If all leading digits are 9, increase the number of digits
6093
+ ** by adding a new 0 to the front */
6094
+ for(i=0; i<N && p->a[i]==9; i++){}
6095
+ if( i==N ){
6096
+ decimal_expand(p, p->nDigit+1, 0);
6097
+ if( p->oom ) return;
6098
+ }
6099
+
6100
+ /* Do the rounding */
60896101
p->a[N-1]++;
60906102
for(i=N-1; i>0 && p->a[i]>9; i--){
60916103
p->a[i] = 0;
60926104
p->a[i-1]++;
60936105
}
6094
- if( p->a[0]>9 ){
6095
- p->a[0] = 1;
6096
- p->nFrac--;
6097
- }
6106
+ assert( p->a[0]<=9 );
60986107
}
60996108
memset(&p->a[N], 0, p->nDigit - N);
61006109
}
61016110
61026111
/*
@@ -8378,19 +8387,26 @@
83788387
}
83798388
}else{
83808389
iMin = iMax = sqlite3_value_int64(argv[iArg++]);
83818390
}
83828391
}else{
8383
- if( idxNum & 0x0300 ){ /* value>X or value>=X */
8392
+ if( idxNum & 0x0300 ){ /* value>X (0x200) or value>=X (0x100) */
83848393
if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
83858394
double r = sqlite3_value_double(argv[iArg++]);
8386
- if( r<(double)SMALLEST_INT64 ){
8395
+ if( r<=(double)SMALLEST_INT64 ){
83878396
iMin = SMALLEST_INT64;
8388
- }else if( (idxNum & 0x0200)!=0 && r==seriesCeil(r) ){
8389
- iMin = (sqlite3_int64)seriesCeil(r)+1;
8397
+ }else if( r>(double)LARGEST_INT64 ){
8398
+ goto series_no_rows;
83908399
}else{
83918400
iMin = (sqlite3_int64)seriesCeil(r);
8401
+ if( iMin<0 && r>0.0 ){
8402
+ iMin = LARGEST_INT64;
8403
+ }
8404
+ if( (idxNum & 0x0200)!=0 && r==seriesCeil(r) ){
8405
+ if( iMin==LARGEST_INT64 ) goto series_no_rows;
8406
+ iMin++;
8407
+ }
83928408
}
83938409
}else{
83948410
iMin = sqlite3_value_int64(argv[iArg++]);
83958411
if( (idxNum & 0x0200)!=0 ){
83968412
if( iMin==LARGEST_INT64 ){
@@ -8399,19 +8415,25 @@
83998415
iMin++;
84008416
}
84018417
}
84028418
}
84038419
}
8404
- if( idxNum & 0x3000 ){ /* value<X or value<=X */
8420
+ if( idxNum & 0x3000 ){ /* value<X (0x2000) or value<=X (0x1000) */
84058421
if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
84068422
double r = sqlite3_value_double(argv[iArg++]);
8407
- if( r>(double)LARGEST_INT64 ){
8423
+ if( r>=(double)LARGEST_INT64 ){
84088424
iMax = LARGEST_INT64;
8409
- }else if( (idxNum & 0x2000)!=0 && r==seriesFloor(r) ){
8410
- iMax = ((sqlite3_int64)r)-1;
8425
+ }else if( r<=(double)SMALLEST_INT64 ){
8426
+ goto series_no_rows;
84118427
}else{
84128428
iMax = (sqlite3_int64)seriesFloor(r);
8429
+ if( iMax<0 && r>0.0 ){
8430
+ iMax = LARGEST_INT64;
8431
+ }else if( (idxNum & 0x2000)!=0 && r==seriesFloor(r) ){
8432
+ if( iMax==SMALLEST_INT64 ) goto series_no_rows;
8433
+ iMax--;
8434
+ }
84138435
}
84148436
}else{
84158437
iMax = sqlite3_value_int64(argv[iArg++]);
84168438
if( idxNum & 0x2000 ){
84178439
if( iMax==SMALLEST_INT64 ){
@@ -10884,10 +10906,13 @@
1088410906
** Return NULL if unable.
1088510907
**
1088610908
** The file or directory X is not required to exist. The answer is formed
1088710909
** by calling system realpath() on the prefix of X that does exist and
1088810910
** appending the tail of X that does not (yet) exist.
10911
+**
10912
+** FIXME: This routine sometimes returns NULL rather than raising
10913
+** an SQLITE_NOMEM error if an OOM is encountered.
1088910914
*/
1089010915
static void realpathFunc(
1089110916
sqlite3_context *context,
1089210917
int argc,
1089310918
sqlite3_value **argv
@@ -10906,10 +10931,11 @@
1090610931
(void)argc;
1090710932
zPath = (const char*)sqlite3_value_text(argv[0]);
1090810933
if( zPath==0 ) return;
1090910934
if( zPath[0]==0 ) zPath = ".";
1091010935
zCopy = sqlite3_mprintf("%s",zPath);
10936
+ if( zCopy==0 ) return;
1091110937
len = strlen(zCopy);
1091210938
while( len>1 && (zCopy[len-1]=='/' || (isWin && zCopy[len-1]=='\\')) ){
1091310939
len--;
1091410940
}
1091510941
zCopy[len] = 0;
@@ -12699,10 +12725,11 @@
1269912725
1270012726
for(p=pCsr->pFreeEntry; p; p=pNext){
1270112727
pNext = p->pNext;
1270212728
zipfileEntryFree(p);
1270312729
}
12730
+ pCsr->pFreeEntry = 0;
1270412731
}
1270512732
1270612733
/*
1270712734
** Destructor for an ZipfileCsr.
1270812735
*/
@@ -13101,11 +13128,17 @@
1310113128
}
1310213129
}
1310313130
1310413131
if( rc==SQLITE_OK ){
1310513132
u32 *pt = &pNew->mUnixTime;
13106
- pNew->cds.zFile = sqlite3_mprintf("%.*s", nFile, aRead);
13133
+ /* aRead[0..nFile-1] might contain embedded \000 characters
13134
+ ** See Bug 2026-05-31T11:43:05Z */
13135
+ pNew->cds.zFile = sqlite3_malloc64(nFile+1);
13136
+ if( pNew->cds.zFile!=0 ){
13137
+ memcpy(pNew->cds.zFile, aRead, nFile);
13138
+ pNew->cds.zFile[nFile] = 0;
13139
+ }
1310713140
pNew->aExtra = (u8*)&pNew[1];
1310813141
memcpy(pNew->aExtra, &aRead[nFile], nExtra);
1310913142
if( pNew->cds.zFile==0 ){
1311013143
rc = SQLITE_NOMEM;
1311113144
}else if( 0==zipfileScanExtra(&aRead[nFile], pNew->cds.nExtra, pt) ){
@@ -14205,14 +14238,14 @@
1420514238
ZipfileBuffer body;
1420614239
ZipfileBuffer cds;
1420714240
};
1420814241
1420914242
static int zipfileBufferGrow(ZipfileBuffer *pBuf, i64 nByte){
14210
- if( pBuf->n+nByte>pBuf->nAlloc ){
14243
+ if( (pBuf->nAlloc-pBuf->n)<nByte ){
1421114244
u8 *aNew;
14212
- sqlite3_int64 nNew = pBuf->n ? pBuf->n*2 : 512;
14213
- int nReq = pBuf->n + nByte;
14245
+ i64 nNew = pBuf->n ? (i64)pBuf->n*2 : 512;
14246
+ i64 nReq = pBuf->n + nByte;
1421414247
1421514248
while( nNew<nReq ) nNew = nNew*2;
1421614249
aNew = sqlite3_realloc64(pBuf->a, nNew);
1421714250
if( aNew==0 ) return SQLITE_NOMEM;
1421814251
pBuf->a = aNew;
@@ -16310,11 +16343,11 @@
1631016343
p->pTable = pTab;
1631116344
1631216345
/* The statement the vtab will pass to sqlite3_declare_vtab() */
1631316346
zInner = idxAppendText(&rc, 0, "CREATE TABLE x(");
1631416347
for(i=0; i<pTab->nCol; i++){
16315
- zInner = idxAppendText(&rc, zInner, "%s%Q COLLATE %s",
16348
+ zInner = idxAppendText(&rc, zInner, "%s%Q COLLATE %Q",
1631616349
(i==0 ? "" : ", "), pTab->aCol[i].zName, pTab->aCol[i].zColl
1631716350
);
1631816351
}
1631916352
zInner = idxAppendText(&rc, zInner, ")");
1632016353
@@ -16510,11 +16543,11 @@
1651016543
sqlite3_free(zCols);
1651116544
sqlite3_free(zOrder);
1651216545
return sqlite3_reset(pIndexXInfo);
1651316546
}
1651416547
zCols = idxAppendText(&rc, zCols,
16515
- "%sx.%Q IS sqlite_expert_rem(%d, x.%Q) COLLATE %s",
16548
+ "%sx.%Q IS sqlite_expert_rem(%d, x.%Q) COLLATE %Q",
1651616549
zComma, zName, nCol, zName, zColl
1651716550
);
1651816551
zOrder = idxAppendText(&rc, zOrder, "%s%d", zComma, ++nCol);
1651916552
}
1652016553
sqlite3_reset(pIndexXInfo);
@@ -25163,11 +25196,11 @@
2516325196
/*
2516425197
** Return true if either the SQLITE_NO_COLOR compile-time option is used
2516525198
** or if the NO_COLOR environment variable exists
2516625199
*/
2516725200
static int shellNoColor(void){
25168
-#ifdef SQLITE_NO_COLOR
25201
+#if defined(SQLITE_NO_COLOR) || defined(SQLITE_SHELL_FIDDLE)
2516925202
return 1;
2517025203
#else
2517125204
return getenv("NO_COLOR")!=0;
2517225205
#endif
2517325206
}
@@ -31316,11 +31349,11 @@
3131631349
typedef struct ArCommand ArCommand;
3131731350
struct ArCommand {
3131831351
u8 eCmd; /* An AR_CMD_* value */
3131931352
u8 bVerbose; /* True if --verbose */
3132031353
u8 bZip; /* True if the archive is a ZIP */
31321
- u8 bDryRun; /* True if --dry-run */
31354
+ u8 bDryRun; /* 1 for --dry-run, 2 for --debug */
3132231355
u8 bAppend; /* True if --append */
3132331356
u8 bGlob; /* True if --glob */
3132431357
u8 fromCmdLine; /* Run from -A instead of .archive */
3132531358
int nArg; /* Number of command arguments */
3132631359
char *zSrcTable; /* "sqlar", "zipfile($file)" or "zip" */
@@ -31378,10 +31411,11 @@
3137831411
#define AR_SWITCH_FILE 9
3137931412
#define AR_SWITCH_DIRECTORY 10
3138031413
#define AR_SWITCH_APPEND 11
3138131414
#define AR_SWITCH_DRYRUN 12
3138231415
#define AR_SWITCH_GLOB 13
31416
+#define AR_SWITCH_DEBUG 14
3138331417
3138431418
static int arProcessSwitch(ArCommand *pAr, int eSwitch, const char *zArg){
3138531419
switch( eSwitch ){
3138631420
case AR_CMD_CREATE:
3138731421
case AR_CMD_EXTRACT:
@@ -31395,11 +31429,14 @@
3139531429
}
3139631430
pAr->eCmd = eSwitch;
3139731431
break;
3139831432
3139931433
case AR_SWITCH_DRYRUN:
31400
- pAr->bDryRun = 1;
31434
+ if( pAr->bDryRun<2 ) pAr->bDryRun = 1;
31435
+ break;
31436
+ case AR_SWITCH_DEBUG:
31437
+ pAr->bDryRun = 2;
3140131438
break;
3140231439
case AR_SWITCH_GLOB:
3140331440
pAr->bGlob = 1;
3140431441
break;
3140531442
case AR_SWITCH_VERBOSE:
@@ -31446,10 +31483,11 @@
3144631483
{ "verbose", 'v', AR_SWITCH_VERBOSE, 0 },
3144731484
{ "file", 'f', AR_SWITCH_FILE, 1 },
3144831485
{ "append", 'a', AR_SWITCH_APPEND, 1 },
3144931486
{ "directory", 'C', AR_SWITCH_DIRECTORY, 1 },
3145031487
{ "dryrun", 'n', AR_SWITCH_DRYRUN, 0 },
31488
+ { "debug", 0, AR_SWITCH_DEBUG, 0 },
3145131489
{ "glob", 'g', AR_SWITCH_GLOB, 0 },
3145231490
};
3145331491
int nSwitch = sizeof(aSwitch) / sizeof(struct ArSwitch);
3145431492
struct ArSwitch *pEnd = &aSwitch[nSwitch];
3145531493
@@ -31746,18 +31784,41 @@
3174631784
3174731785
/*
3174831786
** Implementation of .ar "eXtract" command.
3174931787
*/
3175031788
static int arExtractCommand(ArCommand *pAr){
31789
+ /* The zSql1[] string is a template for the query that does the
31790
+ ** extraction. Notes:
31791
+ **
31792
+ ** * $dir is the directory into which the archive is to be extracted
31793
+ ** * $pass is the integer pass number: 0, 1, or 2
31794
+ ** * The dest CTE is created so that realpath($dir) only needs
31795
+ ** to be called once.
31796
+ */
3175131797
const char *zSql1 =
31752
- "WITH dest(dpath,dlen) AS (SELECT realpath($dir),length(realpath($dir)))\n"
31753
- "SELECT ($dir || name),\n"
31754
- " CASE WHEN $dryrun THEN 0\n"
31755
- " ELSE writefile($dir||name, %s, mode, mtime) END\n"
31798
+ "WITH dest(dpath,dlen) AS (\n"
31799
+#ifdef _WIN32
31800
+ " SELECT realpath($dir) || '\\',\n"
31801
+#else
31802
+ " SELECT realpath($dir) || '/',\n"
31803
+#endif
31804
+ " 1+length(realpath($dir))\n"
31805
+ ")\n"
31806
+ "SELECT\n"
31807
+ " ($dir || name),\n"
31808
+ " CASE $dryrun\n" /* vv--- azExtraArg */
31809
+ " WHEN 0 THEN writefile($dir||name, %s, mode, mtime)\n"
31810
+ " WHEN 1 THEN 0\n"
31811
+ " ELSE shell_putsnl(format('writefile(%%Q,%%s,%%0o,%%d)',"
31812
+ "$dir||name,quote(%s),mode,mtime)) IS NULL\n"
31813
+ " END\n" /* ^^--- azExtraArg */
3175631814
" FROM dest CROSS JOIN %s\n"
31757
- " WHERE (%s)\n"
31758
- " AND (data IS NULL OR $pass==0)\n" /* Dirs both passes */
31815
+ " WHERE (%s)\n" /* ^^-- pAr->zSrcTable */
31816
+ /* ^^--- zWhere */
31817
+ " AND (CASE $pass WHEN 0 THEN (mode&0xf000)<>0xa000\n"
31818
+ " WHEN 1 THEN (mode&0xf000)=0xa000\n"
31819
+ " ELSE data IS NULL END)\n"
3175931820
" AND dpath=substr(realpath($dir||name),1,dlen)\n" /* No escapes */
3176031821
" AND name NOT GLOB '*..[/\\]*'\n"; /* No /../ in paths */
3176131822
3176231823
const char *azExtraArg[] = {
3176331824
"sqlar_uncompress(data, sz)",
@@ -31784,39 +31845,48 @@
3178431845
}
3178531846
if( zDir==0 ) rc = SQLITE_NOMEM;
3178631847
}
3178731848
3178831849
shellPreparePrintf(pAr->db, &rc, &pSql, zSql1,
31789
- azExtraArg[pAr->bZip], pAr->zSrcTable, zWhere
31850
+ azExtraArg[pAr->bZip],
31851
+ azExtraArg[pAr->bZip],
31852
+ pAr->zSrcTable,
31853
+ zWhere
3179031854
);
3179131855
3179231856
if( rc==SQLITE_OK ){
3179331857
j = sqlite3_bind_parameter_index(pSql, "$dir");
3179431858
sqlite3_bind_text(pSql, j, zDir, -1, SQLITE_STATIC);
3179531859
j = sqlite3_bind_parameter_index(pSql, "$dryrun");
3179631860
sqlite3_bind_int(pSql, j, pAr->bDryRun);
3179731861
3179831862
/* Run the SELECT statement twice
31799
- ** (0) writefile() all files and directories
31800
- ** (1) writefile() for directory again
31801
- ** The second pass is so that the timestamps for extracted directories
31863
+ ** (0) writefile() files and directories
31864
+ ** (1) writefile() symlinks
31865
+ ** (2) writefile() for directory again
31866
+ ** The third pass is so that the timestamps for extracted directories
3180231867
** will be reset to the value in the archive, since populating them
3180331868
** in the first pass will have changed the timestamp. */
31804
- for(i=0; i<2; i++){
31869
+ for(i=0; i<3; i++){
31870
+ if( pAr->bDryRun>=2 ){
31871
+ cli_printf(pAr->out, "*** BEGIN PASS %d ***\n", i+1);
31872
+ }
3180531873
j = sqlite3_bind_parameter_index(pSql, "$pass");
3180631874
sqlite3_bind_int(pSql, j, i);
31807
- if( pAr->bDryRun ){
31875
+ if( pAr->bDryRun && i==0 ){
3180831876
cli_printf(pAr->out, "%s\n", sqlite3_sql(pSql));
31809
- if( pAr->bVerbose==0 ) break;
3181031877
}
3181131878
while( rc==SQLITE_OK && SQLITE_ROW==sqlite3_step(pSql) ){
3181231879
if( i==0 && pAr->bVerbose ){
3181331880
cli_printf(pAr->out, "%s\n", sqlite3_column_text(pSql, 0));
3181431881
}
3181531882
}
31816
- if( pAr->bDryRun ) break;
31883
+ if( pAr->bDryRun==1 ) break;
3181731884
shellReset(&rc, pSql);
31885
+ if( pAr->bDryRun>=2 ){
31886
+ cli_printf(pAr->out, "*** END PASS %d ***\n", i+1);
31887
+ }
3181831888
}
3181931889
shellFinalize(&rc, pSql);
3182031890
}
3182131891
3182231892
sqlite3_free(zDir);
@@ -32327,11 +32397,11 @@
3232732397
;
3232832398
static const char * const zCollectVar = "\
3232932399
SELECT\
3233032400
'('||x'0a'\
3233132401
|| group_concat(\
32332
- cname||' ANY',\
32402
+ cname,\
3233332403
','||iif((cpos-1)%4>0, ' ', x'0a'||' '))\
3233432404
||')' AS ColsSpec \
3233532405
FROM (\
3233632406
SELECT cpos, printf('\"%w\"',printf('%!.*s%s', nlen-chop,name,suff)) AS cname \
3233732407
FROM ColNames ORDER BY cpos\
@@ -37939,11 +38009,11 @@
3793938009
}
3794038010
3794138011
/*
3794238012
** The callback from atexit().
3794338013
*/
37944
-static void abnormalExit(void){
38014
+static void SQLITE_CDECL abnormalExit(void){
3794538015
if( seenInterrupt ) eputz("Program interrupted.\n");
3794638016
if( globalShellState ){
3794738017
clearTempFile(globalShellState, 1, 1);
3794838018
}
3794938019
}
@@ -38498,12 +38568,14 @@
3849838568
}else if( cli_strcmp(z,"-nullvalue")==0 ){
3849938569
modeSetStr(&data.mode.spec.zNull,
3850038570
cmdline_option_value(argc,argv,++i));
3850138571
}else if( cli_strcmp(z,"-header")==0 ){
3850238572
data.mode.spec.bTitles = QRF_Yes;
38573
+ data.mode.mFlags |= MFLG_HDR;
3850338574
}else if( cli_strcmp(z,"-noheader")==0 ){
3850438575
data.mode.spec.bTitles = QRF_No;
38576
+ data.mode.mFlags |= MFLG_HDR;
3850538577
}else if( cli_strcmp(z,"-echo")==0 ){
3850638578
data.mode.mFlags |= MFLG_ECHO;
3850738579
}else if( cli_strcmp(z,"-eqp")==0 ){
3850838580
data.mode.autoEQP = AUTOEQP_on;
3850938581
}else if( cli_strcmp(z,"-eqpfull")==0 ){
3851038582
--- extsrc/shell.c
+++ extsrc/shell.c
@@ -5424,11 +5424,11 @@
5424 }
5425 j = 0;
5426 }else{
5427 i = 0;
5428 }
5429 (void)memcpy(&p->buffer[j], &data[i], len - i);
5430 }
5431
5432 /* Compute a string using sqlite3_vsnprintf() and hash it */
5433 static void hash_step_vformat(
5434 SHA1Context *p, /* Add content to this context */
@@ -6071,32 +6071,41 @@
6071 }
6072 z[i] = 0;
6073 sqlite3_result_text(pCtx, z, i, sqlite3_free);
6074 }
6075
 
 
 
6076 /*
6077 ** Round a decimal value to N significant digits. N must be positive.
6078 */
6079 static void decimal_round(Decimal *p, int N){
6080 int i;
6081 int nZero;
6082 if( N<1 ) return;
6083 if( p==0 ) return;
6084 if( p->nDigit<=N ) return;
6085 for(nZero=0; nZero<p->nDigit && p->a[nZero]==0; nZero++){}
6086 N += nZero;
6087 if( p->nDigit<=N ) return;
6088 if( p->a[N]>4 ){
 
 
 
 
 
 
 
 
 
6089 p->a[N-1]++;
6090 for(i=N-1; i>0 && p->a[i]>9; i--){
6091 p->a[i] = 0;
6092 p->a[i-1]++;
6093 }
6094 if( p->a[0]>9 ){
6095 p->a[0] = 1;
6096 p->nFrac--;
6097 }
6098 }
6099 memset(&p->a[N], 0, p->nDigit - N);
6100 }
6101
6102 /*
@@ -8378,19 +8387,26 @@
8378 }
8379 }else{
8380 iMin = iMax = sqlite3_value_int64(argv[iArg++]);
8381 }
8382 }else{
8383 if( idxNum & 0x0300 ){ /* value>X or value>=X */
8384 if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
8385 double r = sqlite3_value_double(argv[iArg++]);
8386 if( r<(double)SMALLEST_INT64 ){
8387 iMin = SMALLEST_INT64;
8388 }else if( (idxNum & 0x0200)!=0 && r==seriesCeil(r) ){
8389 iMin = (sqlite3_int64)seriesCeil(r)+1;
8390 }else{
8391 iMin = (sqlite3_int64)seriesCeil(r);
 
 
 
 
 
 
 
8392 }
8393 }else{
8394 iMin = sqlite3_value_int64(argv[iArg++]);
8395 if( (idxNum & 0x0200)!=0 ){
8396 if( iMin==LARGEST_INT64 ){
@@ -8399,19 +8415,25 @@
8399 iMin++;
8400 }
8401 }
8402 }
8403 }
8404 if( idxNum & 0x3000 ){ /* value<X or value<=X */
8405 if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
8406 double r = sqlite3_value_double(argv[iArg++]);
8407 if( r>(double)LARGEST_INT64 ){
8408 iMax = LARGEST_INT64;
8409 }else if( (idxNum & 0x2000)!=0 && r==seriesFloor(r) ){
8410 iMax = ((sqlite3_int64)r)-1;
8411 }else{
8412 iMax = (sqlite3_int64)seriesFloor(r);
 
 
 
 
 
 
8413 }
8414 }else{
8415 iMax = sqlite3_value_int64(argv[iArg++]);
8416 if( idxNum & 0x2000 ){
8417 if( iMax==SMALLEST_INT64 ){
@@ -10884,10 +10906,13 @@
10884 ** Return NULL if unable.
10885 **
10886 ** The file or directory X is not required to exist. The answer is formed
10887 ** by calling system realpath() on the prefix of X that does exist and
10888 ** appending the tail of X that does not (yet) exist.
 
 
 
10889 */
10890 static void realpathFunc(
10891 sqlite3_context *context,
10892 int argc,
10893 sqlite3_value **argv
@@ -10906,10 +10931,11 @@
10906 (void)argc;
10907 zPath = (const char*)sqlite3_value_text(argv[0]);
10908 if( zPath==0 ) return;
10909 if( zPath[0]==0 ) zPath = ".";
10910 zCopy = sqlite3_mprintf("%s",zPath);
 
10911 len = strlen(zCopy);
10912 while( len>1 && (zCopy[len-1]=='/' || (isWin && zCopy[len-1]=='\\')) ){
10913 len--;
10914 }
10915 zCopy[len] = 0;
@@ -12699,10 +12725,11 @@
12699
12700 for(p=pCsr->pFreeEntry; p; p=pNext){
12701 pNext = p->pNext;
12702 zipfileEntryFree(p);
12703 }
 
12704 }
12705
12706 /*
12707 ** Destructor for an ZipfileCsr.
12708 */
@@ -13101,11 +13128,17 @@
13101 }
13102 }
13103
13104 if( rc==SQLITE_OK ){
13105 u32 *pt = &pNew->mUnixTime;
13106 pNew->cds.zFile = sqlite3_mprintf("%.*s", nFile, aRead);
 
 
 
 
 
 
13107 pNew->aExtra = (u8*)&pNew[1];
13108 memcpy(pNew->aExtra, &aRead[nFile], nExtra);
13109 if( pNew->cds.zFile==0 ){
13110 rc = SQLITE_NOMEM;
13111 }else if( 0==zipfileScanExtra(&aRead[nFile], pNew->cds.nExtra, pt) ){
@@ -14205,14 +14238,14 @@
14205 ZipfileBuffer body;
14206 ZipfileBuffer cds;
14207 };
14208
14209 static int zipfileBufferGrow(ZipfileBuffer *pBuf, i64 nByte){
14210 if( pBuf->n+nByte>pBuf->nAlloc ){
14211 u8 *aNew;
14212 sqlite3_int64 nNew = pBuf->n ? pBuf->n*2 : 512;
14213 int nReq = pBuf->n + nByte;
14214
14215 while( nNew<nReq ) nNew = nNew*2;
14216 aNew = sqlite3_realloc64(pBuf->a, nNew);
14217 if( aNew==0 ) return SQLITE_NOMEM;
14218 pBuf->a = aNew;
@@ -16310,11 +16343,11 @@
16310 p->pTable = pTab;
16311
16312 /* The statement the vtab will pass to sqlite3_declare_vtab() */
16313 zInner = idxAppendText(&rc, 0, "CREATE TABLE x(");
16314 for(i=0; i<pTab->nCol; i++){
16315 zInner = idxAppendText(&rc, zInner, "%s%Q COLLATE %s",
16316 (i==0 ? "" : ", "), pTab->aCol[i].zName, pTab->aCol[i].zColl
16317 );
16318 }
16319 zInner = idxAppendText(&rc, zInner, ")");
16320
@@ -16510,11 +16543,11 @@
16510 sqlite3_free(zCols);
16511 sqlite3_free(zOrder);
16512 return sqlite3_reset(pIndexXInfo);
16513 }
16514 zCols = idxAppendText(&rc, zCols,
16515 "%sx.%Q IS sqlite_expert_rem(%d, x.%Q) COLLATE %s",
16516 zComma, zName, nCol, zName, zColl
16517 );
16518 zOrder = idxAppendText(&rc, zOrder, "%s%d", zComma, ++nCol);
16519 }
16520 sqlite3_reset(pIndexXInfo);
@@ -25163,11 +25196,11 @@
25163 /*
25164 ** Return true if either the SQLITE_NO_COLOR compile-time option is used
25165 ** or if the NO_COLOR environment variable exists
25166 */
25167 static int shellNoColor(void){
25168 #ifdef SQLITE_NO_COLOR
25169 return 1;
25170 #else
25171 return getenv("NO_COLOR")!=0;
25172 #endif
25173 }
@@ -31316,11 +31349,11 @@
31316 typedef struct ArCommand ArCommand;
31317 struct ArCommand {
31318 u8 eCmd; /* An AR_CMD_* value */
31319 u8 bVerbose; /* True if --verbose */
31320 u8 bZip; /* True if the archive is a ZIP */
31321 u8 bDryRun; /* True if --dry-run */
31322 u8 bAppend; /* True if --append */
31323 u8 bGlob; /* True if --glob */
31324 u8 fromCmdLine; /* Run from -A instead of .archive */
31325 int nArg; /* Number of command arguments */
31326 char *zSrcTable; /* "sqlar", "zipfile($file)" or "zip" */
@@ -31378,10 +31411,11 @@
31378 #define AR_SWITCH_FILE 9
31379 #define AR_SWITCH_DIRECTORY 10
31380 #define AR_SWITCH_APPEND 11
31381 #define AR_SWITCH_DRYRUN 12
31382 #define AR_SWITCH_GLOB 13
 
31383
31384 static int arProcessSwitch(ArCommand *pAr, int eSwitch, const char *zArg){
31385 switch( eSwitch ){
31386 case AR_CMD_CREATE:
31387 case AR_CMD_EXTRACT:
@@ -31395,11 +31429,14 @@
31395 }
31396 pAr->eCmd = eSwitch;
31397 break;
31398
31399 case AR_SWITCH_DRYRUN:
31400 pAr->bDryRun = 1;
 
 
 
31401 break;
31402 case AR_SWITCH_GLOB:
31403 pAr->bGlob = 1;
31404 break;
31405 case AR_SWITCH_VERBOSE:
@@ -31446,10 +31483,11 @@
31446 { "verbose", 'v', AR_SWITCH_VERBOSE, 0 },
31447 { "file", 'f', AR_SWITCH_FILE, 1 },
31448 { "append", 'a', AR_SWITCH_APPEND, 1 },
31449 { "directory", 'C', AR_SWITCH_DIRECTORY, 1 },
31450 { "dryrun", 'n', AR_SWITCH_DRYRUN, 0 },
 
31451 { "glob", 'g', AR_SWITCH_GLOB, 0 },
31452 };
31453 int nSwitch = sizeof(aSwitch) / sizeof(struct ArSwitch);
31454 struct ArSwitch *pEnd = &aSwitch[nSwitch];
31455
@@ -31746,18 +31784,41 @@
31746
31747 /*
31748 ** Implementation of .ar "eXtract" command.
31749 */
31750 static int arExtractCommand(ArCommand *pAr){
 
 
 
 
 
 
 
 
31751 const char *zSql1 =
31752 "WITH dest(dpath,dlen) AS (SELECT realpath($dir),length(realpath($dir)))\n"
31753 "SELECT ($dir || name),\n"
31754 " CASE WHEN $dryrun THEN 0\n"
31755 " ELSE writefile($dir||name, %s, mode, mtime) END\n"
 
 
 
 
 
 
 
 
 
 
 
 
31756 " FROM dest CROSS JOIN %s\n"
31757 " WHERE (%s)\n"
31758 " AND (data IS NULL OR $pass==0)\n" /* Dirs both passes */
 
 
 
31759 " AND dpath=substr(realpath($dir||name),1,dlen)\n" /* No escapes */
31760 " AND name NOT GLOB '*..[/\\]*'\n"; /* No /../ in paths */
31761
31762 const char *azExtraArg[] = {
31763 "sqlar_uncompress(data, sz)",
@@ -31784,39 +31845,48 @@
31784 }
31785 if( zDir==0 ) rc = SQLITE_NOMEM;
31786 }
31787
31788 shellPreparePrintf(pAr->db, &rc, &pSql, zSql1,
31789 azExtraArg[pAr->bZip], pAr->zSrcTable, zWhere
 
 
 
31790 );
31791
31792 if( rc==SQLITE_OK ){
31793 j = sqlite3_bind_parameter_index(pSql, "$dir");
31794 sqlite3_bind_text(pSql, j, zDir, -1, SQLITE_STATIC);
31795 j = sqlite3_bind_parameter_index(pSql, "$dryrun");
31796 sqlite3_bind_int(pSql, j, pAr->bDryRun);
31797
31798 /* Run the SELECT statement twice
31799 ** (0) writefile() all files and directories
31800 ** (1) writefile() for directory again
31801 ** The second pass is so that the timestamps for extracted directories
 
31802 ** will be reset to the value in the archive, since populating them
31803 ** in the first pass will have changed the timestamp. */
31804 for(i=0; i<2; i++){
 
 
 
31805 j = sqlite3_bind_parameter_index(pSql, "$pass");
31806 sqlite3_bind_int(pSql, j, i);
31807 if( pAr->bDryRun ){
31808 cli_printf(pAr->out, "%s\n", sqlite3_sql(pSql));
31809 if( pAr->bVerbose==0 ) break;
31810 }
31811 while( rc==SQLITE_OK && SQLITE_ROW==sqlite3_step(pSql) ){
31812 if( i==0 && pAr->bVerbose ){
31813 cli_printf(pAr->out, "%s\n", sqlite3_column_text(pSql, 0));
31814 }
31815 }
31816 if( pAr->bDryRun ) break;
31817 shellReset(&rc, pSql);
 
 
 
31818 }
31819 shellFinalize(&rc, pSql);
31820 }
31821
31822 sqlite3_free(zDir);
@@ -32327,11 +32397,11 @@
32327 ;
32328 static const char * const zCollectVar = "\
32329 SELECT\
32330 '('||x'0a'\
32331 || group_concat(\
32332 cname||' ANY',\
32333 ','||iif((cpos-1)%4>0, ' ', x'0a'||' '))\
32334 ||')' AS ColsSpec \
32335 FROM (\
32336 SELECT cpos, printf('\"%w\"',printf('%!.*s%s', nlen-chop,name,suff)) AS cname \
32337 FROM ColNames ORDER BY cpos\
@@ -37939,11 +38009,11 @@
37939 }
37940
37941 /*
37942 ** The callback from atexit().
37943 */
37944 static void abnormalExit(void){
37945 if( seenInterrupt ) eputz("Program interrupted.\n");
37946 if( globalShellState ){
37947 clearTempFile(globalShellState, 1, 1);
37948 }
37949 }
@@ -38498,12 +38568,14 @@
38498 }else if( cli_strcmp(z,"-nullvalue")==0 ){
38499 modeSetStr(&data.mode.spec.zNull,
38500 cmdline_option_value(argc,argv,++i));
38501 }else if( cli_strcmp(z,"-header")==0 ){
38502 data.mode.spec.bTitles = QRF_Yes;
 
38503 }else if( cli_strcmp(z,"-noheader")==0 ){
38504 data.mode.spec.bTitles = QRF_No;
 
38505 }else if( cli_strcmp(z,"-echo")==0 ){
38506 data.mode.mFlags |= MFLG_ECHO;
38507 }else if( cli_strcmp(z,"-eqp")==0 ){
38508 data.mode.autoEQP = AUTOEQP_on;
38509 }else if( cli_strcmp(z,"-eqpfull")==0 ){
38510
--- extsrc/shell.c
+++ extsrc/shell.c
@@ -5424,11 +5424,11 @@
5424 }
5425 j = 0;
5426 }else{
5427 i = 0;
5428 }
5429 if( len-i>0 ) (void)memcpy(&p->buffer[j], &data[i], len - i);
5430 }
5431
5432 /* Compute a string using sqlite3_vsnprintf() and hash it */
5433 static void hash_step_vformat(
5434 SHA1Context *p, /* Add content to this context */
@@ -6071,32 +6071,41 @@
6071 }
6072 z[i] = 0;
6073 sqlite3_result_text(pCtx, z, i, sqlite3_free);
6074 }
6075
6076 /* Forward declaration */
6077 static void decimal_expand(Decimal *p, int nDigit, int nFrac);
6078
6079 /*
6080 ** Round a decimal value to N significant digits. N must be positive.
6081 */
6082 static void decimal_round(Decimal *p, int N){
6083 int i;
6084 int nZero; /* Number of leading zeros */
6085 if( N<1 ) return;
6086 if( p==0 ) return;
6087 if( p->nDigit<=N ) return;
6088 for(nZero=0; nZero<p->nDigit && p->a[nZero]==0; nZero++){}
6089 N += nZero;
6090 if( p->nDigit<=N ) return;
6091 if( p->a[N]>=5 ){
6092 /* If all leading digits are 9, increase the number of digits
6093 ** by adding a new 0 to the front */
6094 for(i=0; i<N && p->a[i]==9; i++){}
6095 if( i==N ){
6096 decimal_expand(p, p->nDigit+1, 0);
6097 if( p->oom ) return;
6098 }
6099
6100 /* Do the rounding */
6101 p->a[N-1]++;
6102 for(i=N-1; i>0 && p->a[i]>9; i--){
6103 p->a[i] = 0;
6104 p->a[i-1]++;
6105 }
6106 assert( p->a[0]<=9 );
 
 
 
6107 }
6108 memset(&p->a[N], 0, p->nDigit - N);
6109 }
6110
6111 /*
@@ -8378,19 +8387,26 @@
8387 }
8388 }else{
8389 iMin = iMax = sqlite3_value_int64(argv[iArg++]);
8390 }
8391 }else{
8392 if( idxNum & 0x0300 ){ /* value>X (0x200) or value>=X (0x100) */
8393 if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
8394 double r = sqlite3_value_double(argv[iArg++]);
8395 if( r<=(double)SMALLEST_INT64 ){
8396 iMin = SMALLEST_INT64;
8397 }else if( r>(double)LARGEST_INT64 ){
8398 goto series_no_rows;
8399 }else{
8400 iMin = (sqlite3_int64)seriesCeil(r);
8401 if( iMin<0 && r>0.0 ){
8402 iMin = LARGEST_INT64;
8403 }
8404 if( (idxNum & 0x0200)!=0 && r==seriesCeil(r) ){
8405 if( iMin==LARGEST_INT64 ) goto series_no_rows;
8406 iMin++;
8407 }
8408 }
8409 }else{
8410 iMin = sqlite3_value_int64(argv[iArg++]);
8411 if( (idxNum & 0x0200)!=0 ){
8412 if( iMin==LARGEST_INT64 ){
@@ -8399,19 +8415,25 @@
8415 iMin++;
8416 }
8417 }
8418 }
8419 }
8420 if( idxNum & 0x3000 ){ /* value<X (0x2000) or value<=X (0x1000) */
8421 if( sqlite3_value_numeric_type(argv[iArg])==SQLITE_FLOAT ){
8422 double r = sqlite3_value_double(argv[iArg++]);
8423 if( r>=(double)LARGEST_INT64 ){
8424 iMax = LARGEST_INT64;
8425 }else if( r<=(double)SMALLEST_INT64 ){
8426 goto series_no_rows;
8427 }else{
8428 iMax = (sqlite3_int64)seriesFloor(r);
8429 if( iMax<0 && r>0.0 ){
8430 iMax = LARGEST_INT64;
8431 }else if( (idxNum & 0x2000)!=0 && r==seriesFloor(r) ){
8432 if( iMax==SMALLEST_INT64 ) goto series_no_rows;
8433 iMax--;
8434 }
8435 }
8436 }else{
8437 iMax = sqlite3_value_int64(argv[iArg++]);
8438 if( idxNum & 0x2000 ){
8439 if( iMax==SMALLEST_INT64 ){
@@ -10884,10 +10906,13 @@
10906 ** Return NULL if unable.
10907 **
10908 ** The file or directory X is not required to exist. The answer is formed
10909 ** by calling system realpath() on the prefix of X that does exist and
10910 ** appending the tail of X that does not (yet) exist.
10911 **
10912 ** FIXME: This routine sometimes returns NULL rather than raising
10913 ** an SQLITE_NOMEM error if an OOM is encountered.
10914 */
10915 static void realpathFunc(
10916 sqlite3_context *context,
10917 int argc,
10918 sqlite3_value **argv
@@ -10906,10 +10931,11 @@
10931 (void)argc;
10932 zPath = (const char*)sqlite3_value_text(argv[0]);
10933 if( zPath==0 ) return;
10934 if( zPath[0]==0 ) zPath = ".";
10935 zCopy = sqlite3_mprintf("%s",zPath);
10936 if( zCopy==0 ) return;
10937 len = strlen(zCopy);
10938 while( len>1 && (zCopy[len-1]=='/' || (isWin && zCopy[len-1]=='\\')) ){
10939 len--;
10940 }
10941 zCopy[len] = 0;
@@ -12699,10 +12725,11 @@
12725
12726 for(p=pCsr->pFreeEntry; p; p=pNext){
12727 pNext = p->pNext;
12728 zipfileEntryFree(p);
12729 }
12730 pCsr->pFreeEntry = 0;
12731 }
12732
12733 /*
12734 ** Destructor for an ZipfileCsr.
12735 */
@@ -13101,11 +13128,17 @@
13128 }
13129 }
13130
13131 if( rc==SQLITE_OK ){
13132 u32 *pt = &pNew->mUnixTime;
13133 /* aRead[0..nFile-1] might contain embedded \000 characters
13134 ** See Bug 2026-05-31T11:43:05Z */
13135 pNew->cds.zFile = sqlite3_malloc64(nFile+1);
13136 if( pNew->cds.zFile!=0 ){
13137 memcpy(pNew->cds.zFile, aRead, nFile);
13138 pNew->cds.zFile[nFile] = 0;
13139 }
13140 pNew->aExtra = (u8*)&pNew[1];
13141 memcpy(pNew->aExtra, &aRead[nFile], nExtra);
13142 if( pNew->cds.zFile==0 ){
13143 rc = SQLITE_NOMEM;
13144 }else if( 0==zipfileScanExtra(&aRead[nFile], pNew->cds.nExtra, pt) ){
@@ -14205,14 +14238,14 @@
14238 ZipfileBuffer body;
14239 ZipfileBuffer cds;
14240 };
14241
14242 static int zipfileBufferGrow(ZipfileBuffer *pBuf, i64 nByte){
14243 if( (pBuf->nAlloc-pBuf->n)<nByte ){
14244 u8 *aNew;
14245 i64 nNew = pBuf->n ? (i64)pBuf->n*2 : 512;
14246 i64 nReq = pBuf->n + nByte;
14247
14248 while( nNew<nReq ) nNew = nNew*2;
14249 aNew = sqlite3_realloc64(pBuf->a, nNew);
14250 if( aNew==0 ) return SQLITE_NOMEM;
14251 pBuf->a = aNew;
@@ -16310,11 +16343,11 @@
16343 p->pTable = pTab;
16344
16345 /* The statement the vtab will pass to sqlite3_declare_vtab() */
16346 zInner = idxAppendText(&rc, 0, "CREATE TABLE x(");
16347 for(i=0; i<pTab->nCol; i++){
16348 zInner = idxAppendText(&rc, zInner, "%s%Q COLLATE %Q",
16349 (i==0 ? "" : ", "), pTab->aCol[i].zName, pTab->aCol[i].zColl
16350 );
16351 }
16352 zInner = idxAppendText(&rc, zInner, ")");
16353
@@ -16510,11 +16543,11 @@
16543 sqlite3_free(zCols);
16544 sqlite3_free(zOrder);
16545 return sqlite3_reset(pIndexXInfo);
16546 }
16547 zCols = idxAppendText(&rc, zCols,
16548 "%sx.%Q IS sqlite_expert_rem(%d, x.%Q) COLLATE %Q",
16549 zComma, zName, nCol, zName, zColl
16550 );
16551 zOrder = idxAppendText(&rc, zOrder, "%s%d", zComma, ++nCol);
16552 }
16553 sqlite3_reset(pIndexXInfo);
@@ -25163,11 +25196,11 @@
25196 /*
25197 ** Return true if either the SQLITE_NO_COLOR compile-time option is used
25198 ** or if the NO_COLOR environment variable exists
25199 */
25200 static int shellNoColor(void){
25201 #if defined(SQLITE_NO_COLOR) || defined(SQLITE_SHELL_FIDDLE)
25202 return 1;
25203 #else
25204 return getenv("NO_COLOR")!=0;
25205 #endif
25206 }
@@ -31316,11 +31349,11 @@
31349 typedef struct ArCommand ArCommand;
31350 struct ArCommand {
31351 u8 eCmd; /* An AR_CMD_* value */
31352 u8 bVerbose; /* True if --verbose */
31353 u8 bZip; /* True if the archive is a ZIP */
31354 u8 bDryRun; /* 1 for --dry-run, 2 for --debug */
31355 u8 bAppend; /* True if --append */
31356 u8 bGlob; /* True if --glob */
31357 u8 fromCmdLine; /* Run from -A instead of .archive */
31358 int nArg; /* Number of command arguments */
31359 char *zSrcTable; /* "sqlar", "zipfile($file)" or "zip" */
@@ -31378,10 +31411,11 @@
31411 #define AR_SWITCH_FILE 9
31412 #define AR_SWITCH_DIRECTORY 10
31413 #define AR_SWITCH_APPEND 11
31414 #define AR_SWITCH_DRYRUN 12
31415 #define AR_SWITCH_GLOB 13
31416 #define AR_SWITCH_DEBUG 14
31417
31418 static int arProcessSwitch(ArCommand *pAr, int eSwitch, const char *zArg){
31419 switch( eSwitch ){
31420 case AR_CMD_CREATE:
31421 case AR_CMD_EXTRACT:
@@ -31395,11 +31429,14 @@
31429 }
31430 pAr->eCmd = eSwitch;
31431 break;
31432
31433 case AR_SWITCH_DRYRUN:
31434 if( pAr->bDryRun<2 ) pAr->bDryRun = 1;
31435 break;
31436 case AR_SWITCH_DEBUG:
31437 pAr->bDryRun = 2;
31438 break;
31439 case AR_SWITCH_GLOB:
31440 pAr->bGlob = 1;
31441 break;
31442 case AR_SWITCH_VERBOSE:
@@ -31446,10 +31483,11 @@
31483 { "verbose", 'v', AR_SWITCH_VERBOSE, 0 },
31484 { "file", 'f', AR_SWITCH_FILE, 1 },
31485 { "append", 'a', AR_SWITCH_APPEND, 1 },
31486 { "directory", 'C', AR_SWITCH_DIRECTORY, 1 },
31487 { "dryrun", 'n', AR_SWITCH_DRYRUN, 0 },
31488 { "debug", 0, AR_SWITCH_DEBUG, 0 },
31489 { "glob", 'g', AR_SWITCH_GLOB, 0 },
31490 };
31491 int nSwitch = sizeof(aSwitch) / sizeof(struct ArSwitch);
31492 struct ArSwitch *pEnd = &aSwitch[nSwitch];
31493
@@ -31746,18 +31784,41 @@
31784
31785 /*
31786 ** Implementation of .ar "eXtract" command.
31787 */
31788 static int arExtractCommand(ArCommand *pAr){
31789 /* The zSql1[] string is a template for the query that does the
31790 ** extraction. Notes:
31791 **
31792 ** * $dir is the directory into which the archive is to be extracted
31793 ** * $pass is the integer pass number: 0, 1, or 2
31794 ** * The dest CTE is created so that realpath($dir) only needs
31795 ** to be called once.
31796 */
31797 const char *zSql1 =
31798 "WITH dest(dpath,dlen) AS (\n"
31799 #ifdef _WIN32
31800 " SELECT realpath($dir) || '\\',\n"
31801 #else
31802 " SELECT realpath($dir) || '/',\n"
31803 #endif
31804 " 1+length(realpath($dir))\n"
31805 ")\n"
31806 "SELECT\n"
31807 " ($dir || name),\n"
31808 " CASE $dryrun\n" /* vv--- azExtraArg */
31809 " WHEN 0 THEN writefile($dir||name, %s, mode, mtime)\n"
31810 " WHEN 1 THEN 0\n"
31811 " ELSE shell_putsnl(format('writefile(%%Q,%%s,%%0o,%%d)',"
31812 "$dir||name,quote(%s),mode,mtime)) IS NULL\n"
31813 " END\n" /* ^^--- azExtraArg */
31814 " FROM dest CROSS JOIN %s\n"
31815 " WHERE (%s)\n" /* ^^-- pAr->zSrcTable */
31816 /* ^^--- zWhere */
31817 " AND (CASE $pass WHEN 0 THEN (mode&0xf000)<>0xa000\n"
31818 " WHEN 1 THEN (mode&0xf000)=0xa000\n"
31819 " ELSE data IS NULL END)\n"
31820 " AND dpath=substr(realpath($dir||name),1,dlen)\n" /* No escapes */
31821 " AND name NOT GLOB '*..[/\\]*'\n"; /* No /../ in paths */
31822
31823 const char *azExtraArg[] = {
31824 "sqlar_uncompress(data, sz)",
@@ -31784,39 +31845,48 @@
31845 }
31846 if( zDir==0 ) rc = SQLITE_NOMEM;
31847 }
31848
31849 shellPreparePrintf(pAr->db, &rc, &pSql, zSql1,
31850 azExtraArg[pAr->bZip],
31851 azExtraArg[pAr->bZip],
31852 pAr->zSrcTable,
31853 zWhere
31854 );
31855
31856 if( rc==SQLITE_OK ){
31857 j = sqlite3_bind_parameter_index(pSql, "$dir");
31858 sqlite3_bind_text(pSql, j, zDir, -1, SQLITE_STATIC);
31859 j = sqlite3_bind_parameter_index(pSql, "$dryrun");
31860 sqlite3_bind_int(pSql, j, pAr->bDryRun);
31861
31862 /* Run the SELECT statement twice
31863 ** (0) writefile() files and directories
31864 ** (1) writefile() symlinks
31865 ** (2) writefile() for directory again
31866 ** The third pass is so that the timestamps for extracted directories
31867 ** will be reset to the value in the archive, since populating them
31868 ** in the first pass will have changed the timestamp. */
31869 for(i=0; i<3; i++){
31870 if( pAr->bDryRun>=2 ){
31871 cli_printf(pAr->out, "*** BEGIN PASS %d ***\n", i+1);
31872 }
31873 j = sqlite3_bind_parameter_index(pSql, "$pass");
31874 sqlite3_bind_int(pSql, j, i);
31875 if( pAr->bDryRun && i==0 ){
31876 cli_printf(pAr->out, "%s\n", sqlite3_sql(pSql));
 
31877 }
31878 while( rc==SQLITE_OK && SQLITE_ROW==sqlite3_step(pSql) ){
31879 if( i==0 && pAr->bVerbose ){
31880 cli_printf(pAr->out, "%s\n", sqlite3_column_text(pSql, 0));
31881 }
31882 }
31883 if( pAr->bDryRun==1 ) break;
31884 shellReset(&rc, pSql);
31885 if( pAr->bDryRun>=2 ){
31886 cli_printf(pAr->out, "*** END PASS %d ***\n", i+1);
31887 }
31888 }
31889 shellFinalize(&rc, pSql);
31890 }
31891
31892 sqlite3_free(zDir);
@@ -32327,11 +32397,11 @@
32397 ;
32398 static const char * const zCollectVar = "\
32399 SELECT\
32400 '('||x'0a'\
32401 || group_concat(\
32402 cname,\
32403 ','||iif((cpos-1)%4>0, ' ', x'0a'||' '))\
32404 ||')' AS ColsSpec \
32405 FROM (\
32406 SELECT cpos, printf('\"%w\"',printf('%!.*s%s', nlen-chop,name,suff)) AS cname \
32407 FROM ColNames ORDER BY cpos\
@@ -37939,11 +38009,11 @@
38009 }
38010
38011 /*
38012 ** The callback from atexit().
38013 */
38014 static void SQLITE_CDECL abnormalExit(void){
38015 if( seenInterrupt ) eputz("Program interrupted.\n");
38016 if( globalShellState ){
38017 clearTempFile(globalShellState, 1, 1);
38018 }
38019 }
@@ -38498,12 +38568,14 @@
38568 }else if( cli_strcmp(z,"-nullvalue")==0 ){
38569 modeSetStr(&data.mode.spec.zNull,
38570 cmdline_option_value(argc,argv,++i));
38571 }else if( cli_strcmp(z,"-header")==0 ){
38572 data.mode.spec.bTitles = QRF_Yes;
38573 data.mode.mFlags |= MFLG_HDR;
38574 }else if( cli_strcmp(z,"-noheader")==0 ){
38575 data.mode.spec.bTitles = QRF_No;
38576 data.mode.mFlags |= MFLG_HDR;
38577 }else if( cli_strcmp(z,"-echo")==0 ){
38578 data.mode.mFlags |= MFLG_ECHO;
38579 }else if( cli_strcmp(z,"-eqp")==0 ){
38580 data.mode.autoEQP = AUTOEQP_on;
38581 }else if( cli_strcmp(z,"-eqpfull")==0 ){
38582
+448 -221
--- extsrc/sqlite3.c
+++ extsrc/sqlite3.c
@@ -16,11 +16,11 @@
1616
** if you want a wrapper to interface SQLite with your choice of programming
1717
** language. The code for the "sqlite3" command-line shell is also in a
1818
** separate file. This file contains only code for the core SQLite library.
1919
**
2020
** The content in this amalgamation comes from Fossil check-in
21
-** 2c605bfb1562d7a3609ad6ffd7446def12f1 with changes in files:
21
+** 3f3fb9b638f59ad982beafb7c117f24ddd3d with changes in files:
2222
**
2323
**
2424
*/
2525
#ifndef SQLITE_AMALGAMATION
2626
#define SQLITE_CORE 1
@@ -467,14 +467,14 @@
467467
** [sqlite3_libversion_number()], [sqlite3_sourceid()],
468468
** [sqlite_version()] and [sqlite_source_id()].
469469
*/
470470
#define SQLITE_VERSION "3.54.0"
471471
#define SQLITE_VERSION_NUMBER 3054000
472
-#define SQLITE_SOURCE_ID "2026-05-30 13:23:25 2c605bfb1562d7a3609ad6ffd7446def12f1ac7084e41b9c6723e998c156501d"
472
+#define SQLITE_SOURCE_ID "2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06"
473473
#define SQLITE_SCM_BRANCH "trunk"
474474
#define SQLITE_SCM_TAGS ""
475
-#define SQLITE_SCM_DATETIME "2026-05-30T13:23:25.636Z"
475
+#define SQLITE_SCM_DATETIME "2026-06-16T13:43:08.110Z"
476476
477477
/*
478478
** CAPI3REF: Run-Time Library Version Numbers
479479
** KEYWORDS: sqlite3_version sqlite3_sourceid
480480
**
@@ -4696,11 +4696,12 @@
46964696
** <dd>The maximum number of columns in a table definition or in the
46974697
** result set of a [SELECT] or the maximum number of columns in an index
46984698
** or in an ORDER BY or GROUP BY clause.</dd>)^
46994699
**
47004700
** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4701
-** <dd>The maximum depth of the parse tree on any expression.</dd>)^
4701
+** <dd>The maximum depth of the parse tree on any expression and
4702
+** the maximum nesting depth for subqueries and VIEWs</dd>)^
47024703
**
47034704
** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
47044705
** <dd>The maximum depth of the LALR(1) parser stack used to analyze
47054706
** input SQL statements.</dd>)^
47064707
**
@@ -4727,11 +4728,12 @@
47274728
** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
47284729
** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
47294730
** <dd>The maximum index number of any [parameter] in an SQL statement.)^
47304731
**
47314732
** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4732
-** <dd>The maximum depth of recursion for triggers.</dd>)^
4733
+** <dd>The maximum depth of recursion for triggers, and the maximum
4734
+** nesting depth for separate triggers.</dd>)^
47334735
**
47344736
** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
47354737
** <dd>The maximum number of auxiliary worker threads that a single
47364738
** [prepared statement] may start.</dd>)^
47374739
** </dl>
@@ -11533,12 +11535,23 @@
1153311535
** reopen S as an in-memory database based on the serialization
1153411536
** contained in P. If S is a NULL pointer, the main database is
1153511537
** used. The serialized database P is N bytes in size. M is the size
1153611538
** of the buffer P, which might be larger than N. If M is larger than
1153711539
** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11538
-** SQLite is permitted to add content to the in-memory database as
11539
-** long as the total size does not exceed M bytes.
11540
+** SQLite is permitted to add content to the in-memory database, in
11541
+** page-sized chunks, as long as the total size does not exceed M bytes.
11542
+**
11543
+** The parameter M must be greater than or equal to N. Ideally, M
11544
+** should have a value which is N+(512&times;K)+20 where K determines how
11545
+** must extra space is available to hold new content as the database
11546
+** grows. K can be 0 if the database is read-only.
11547
+**
11548
+** If the database content in P is malformed in a malicious way then
11549
+** it is possible that SQLite might try to read a few more than N bytes
11550
+** from P. If the veracity of the database content P is uncertain,
11551
+** then applications are advised to allocate about 20 extra bytes on
11552
+** the end of the P buffer to avoid a memory error.
1154011553
**
1154111554
** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
1154211555
** invoke sqlite3_free() on the serialization buffer when the database
1154311556
** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
1154411557
** SQLite will try to increase the buffer size using sqlite3_realloc64()
@@ -15809,10 +15822,17 @@
1580915822
*/
1581015823
#ifndef offsetof
1581115824
# define offsetof(ST,M) ((size_t)((char*)&((ST*)0)->M - (char*)0))
1581215825
#endif
1581315826
15827
+/*
15828
+** sizeof64() is like sizeof(), but always returns a 64-bit value, even
15829
+** on 32-bit builds. This can help to avoid overflow by ensuring 64-bit
15830
+** arithmetic is used consistently in both 32-bit and 64-bit builds.
15831
+*/
15832
+#define sizeof64(X) ((sqlite3_int64)sizeof(X))
15833
+
1581415834
/*
1581515835
** Work around C99 "flex-array" syntax for pre-C99 compilers, so as
1581615836
** to avoid complaints from -fsanitize=strict-bounds.
1581715837
*/
1581815838
#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)
@@ -20928,10 +20948,11 @@
2092820948
int nTab; /* Number of previously allocated VDBE cursors */
2092920949
int nMem; /* Number of memory cells used so far */
2093020950
int szOpAlloc; /* Bytes of memory space allocated for Vdbe.aOp[] */
2093120951
int iSelfTab; /* Table associated with an index on expr, or negative
2093220952
** of the base register during check-constraint eval */
20953
+ int nNestSel; /* Number of nested SELECT statements and/or VIEWs */
2093320954
int nLabel; /* The *negative* of the number of labels used */
2093420955
int nLabelAlloc; /* Number of slots in aLabel */
2093520956
int *aLabel; /* Space to hold the labels */
2093620957
ExprList *pConstExpr;/* Constant expressions */
2093720958
IndexedExpr *pIdxEpr;/* List of expressions used by active indexes */
@@ -33389,12 +33410,12 @@
3338933410
if( flag_alternateform ){
3339033411
/* For %#q, do unistr()-style backslash escapes for
3339133412
** all control characters, and for backslash itself.
3339233413
** For %#Q, do the same but only if there is at least
3339333414
** one control character. */
33394
- u32 nBack = 0;
33395
- u32 nCtrl = 0;
33415
+ i64 nBack = 0;
33416
+ i64 nCtrl = 0;
3339633417
for(k=0; k<i; k++){
3339733418
if( escarg[k]=='\\' ){
3339833419
nBack++;
3339933420
}else if( ((u8*)escarg)[k]<=0x1f ){
3340033421
nCtrl++;
@@ -39255,10 +39276,26 @@
3925539276
******************************************************************************
3925639277
**
3925739278
** This file contains an experimental VFS layer that operates on a
3925839279
** Key/Value storage engine where both keys and values must be pure
3925939280
** text.
39281
+**
39282
+** DEBUG AND TEST
39283
+**
39284
+** For testing on Unix, compile using:
39285
+**
39286
+** make clean sqlite3d CFLAGS='-DSQLITE_OS_KV_OPTIONAL'
39287
+**
39288
+** Then start up a shell using something like:
39289
+**
39290
+** ./sqlite3d 'file:dbname?vfs=kvvfs'
39291
+**
39292
+** Each K/V entry is stored in a separate file in the working
39293
+** directory that has a name like "kvvfs-dbname-*". Due to limitations
39294
+** on the key size, the name of the database must be very short - just
39295
+** a few characters. If the database name is too long, the VFS will
39296
+** malfunction and you will get SQLITE_CORRUPT errors.
3926039297
*/
3926139298
/* #include <sqliteInt.h> */
3926239299
#if SQLITE_OS_KV || (SQLITE_OS_UNIX && defined(SQLITE_OS_KV_OPTIONAL))
3926339300
3926439301
/*****************************************************************************
@@ -39707,16 +39744,18 @@
3970739744
}
3970839745
if( j+n>nOut ) return -1;
3970939746
memset(&aOut[j], 0, n);
3971039747
j += n;
3971139748
if( c==0 || mult==1 ) break; /* progress stalled if mult==1 */
39712
- }else{
39749
+ }else if( j<nOut ){
3971339750
aOut[j] = c<<4;
3971439751
c = kvvfsHexValue[aIn[++i]];
3971539752
if( c<0 ) return -1 /* hex bytes are always in pairs */;
3971639753
aOut[j++] += c;
3971739754
i++;
39755
+ }else{
39756
+ return -1;
3971839757
}
3971939758
}
3972039759
return j;
3972139760
}
3972239761
@@ -40138,10 +40177,22 @@
4013840177
}else{
4013940178
pFile->isJournal = 0;
4014040179
pFile->base.pMethods = &kvvfs_db_io_methods;
4014140180
}
4014240181
if( !pFile->zClass ){
40182
+#ifdef SQLITE_WASM
40183
+ if( strlen(zName) >= (KVRECORD_KEY_SZ
40184
+ - 6 /* "kvvfs-" */
40185
+ - 11 /* "-##########" */) ){
40186
+ return SQLITE_CANTOPEN;
40187
+ }
40188
+#else
40189
+ if( 0!=strcmp(zName, "local") && 0!=strcmp(zName, "session") ){
40190
+ /* Historical naming restriction which journaling depends on. */
40191
+ return SQLITE_CANTOPEN;
40192
+ }
40193
+#endif
4014340194
pFile->zClass = zName;
4014440195
}
4014540196
pFile->aData = sqlite3_malloc64(SQLITE_KVOS_SZ);
4014640197
if( pFile->aData==0 ){
4014740198
return SQLITE_NOMEM;
@@ -52170,15 +52221,33 @@
5217052221
# define sqlite3_win_test_unc_locking 0
5217152222
#endif
5217252223
5217352224
/*
5217452225
** Return true if the string passed as the only argument is likely
52175
-** to be a UNC path. In other words, if it starts with "\\".
52226
+** to be a UNC path. Return false if note.
52227
+**
52228
+** Return true if:
52229
+**
52230
+** (1) The name begins with "\\"
52231
+** (2) But does not begin with "\\?\C:\" where C can be any alphabetic
52232
+** character.
52233
+**
52234
+** For testing, also return true in all cases if the global variable
52235
+** sqlite3_win_test_unc_locking is true.
5217652236
*/
5217752237
static int winIsUNCPath(const char *zFile){
5217852238
if( zFile[0]=='\\' && zFile[1]=='\\' ){
52179
- return 1;
52239
+ if( zFile[2]=='?'
52240
+ && zFile[3]=='\\'
52241
+ && sqlite3Isalpha(zFile[4])
52242
+ && zFile[5]==':'
52243
+ && winIsDirSep(zFile[6])
52244
+ ){
52245
+ return sqlite3_win_test_unc_locking;
52246
+ }else{
52247
+ return 1;
52248
+ }
5218052249
}
5218152250
return sqlite3_win_test_unc_locking;
5218252251
}
5218352252
5218452253
/*
@@ -56926,26 +56995,28 @@
5692656995
szBulk = -1024 * (i64)pcache1.nInitPage;
5692756996
}
5692856997
if( szBulk > pCache->szAlloc*(i64)pCache->nMax ){
5692956998
szBulk = pCache->szAlloc*(i64)pCache->nMax;
5693056999
}
56931
- zBulk = pCache->pBulk = sqlite3Malloc( szBulk );
56932
- sqlite3EndBenignMalloc();
56933
- if( zBulk ){
56934
- int nBulk = sqlite3MallocSize(zBulk)/pCache->szAlloc;
56935
- do{
56936
- PgHdr1 *pX = (PgHdr1*)&zBulk[pCache->szPage];
56937
- pX->page.pBuf = zBulk;
56938
- pX->page.pExtra = (u8*)pX + ROUND8(sizeof(*pX));
56939
- assert( EIGHT_BYTE_ALIGNMENT( pX->page.pExtra ) );
56940
- pX->isBulkLocal = 1;
56941
- pX->isAnchor = 0;
56942
- pX->pNext = pCache->pFree;
56943
- pX->pLruPrev = 0; /* Initializing this saves a valgrind error */
56944
- pCache->pFree = pX;
56945
- zBulk += pCache->szAlloc;
56946
- }while( --nBulk );
57000
+ if( szBulk>=pCache->szAlloc ){
57001
+ zBulk = pCache->pBulk = sqlite3Malloc( szBulk );
57002
+ sqlite3EndBenignMalloc();
57003
+ if( zBulk ){
57004
+ int nBulk = sqlite3MallocSize(zBulk)/pCache->szAlloc;
57005
+ do{
57006
+ PgHdr1 *pX = (PgHdr1*)&zBulk[pCache->szPage];
57007
+ pX->page.pBuf = zBulk;
57008
+ pX->page.pExtra = (u8*)pX + ROUND8(sizeof(*pX));
57009
+ assert( EIGHT_BYTE_ALIGNMENT( pX->page.pExtra ) );
57010
+ pX->isBulkLocal = 1;
57011
+ pX->isAnchor = 0;
57012
+ pX->pNext = pCache->pFree;
57013
+ pX->pLruPrev = 0; /* Initializing this saves a valgrind error */
57014
+ pCache->pFree = pX;
57015
+ zBulk += pCache->szAlloc;
57016
+ }while( --nBulk );
57017
+ }
5694757018
}
5694857019
return pCache->pFree!=0;
5694957020
}
5695057021
5695157022
/*
@@ -59839,73 +59910,84 @@
5983959910
#define pager_set_pagehash(X)
5984059911
#define CHECK_PAGE(x)
5984159912
#endif /* SQLITE_CHECK_PAGES */
5984259913
5984359914
/*
59844
-** When this is called the journal file for pager pPager must be open.
59845
-** This function attempts to read a super-journal file name from the
59846
-** end of the file and, if successful, copies it into memory supplied
59847
-** by the caller. See comments above writeSuperJournal() for the format
59848
-** used to store a super-journal file name at the end of a journal file.
59849
-**
59850
-** zSuper must point to a buffer of at least nSuper bytes allocated by
59851
-** the caller. This should be sqlite3_vfs.mxPathname+1 (to ensure there is
59852
-** enough space to write the super-journal name). If the super-journal
59853
-** name in the journal is longer than nSuper bytes (including a
59854
-** nul-terminator), then this is handled as if no super-journal name
59855
-** were present in the journal.
59856
-**
59857
-** If a super-journal file name is present at the end of the journal
59858
-** file, then it is copied into the buffer pointed to by zSuper. A
59859
-** nul-terminator byte is appended to the buffer following the
59860
-** super-journal file name.
59861
-**
59862
-** If it is determined that no super-journal file name is present
59863
-** zSuper[0] is set to 0 and SQLITE_OK returned.
59864
-**
59865
-** If an error occurs while reading from the journal file, an SQLite
59866
-** error code is returned.
59867
-*/
59868
-static int readSuperJournal(sqlite3_file *pJrnl, char *zSuper, u64 nSuper){
59915
+** Free a buffer allocated by the readSuperJournal() function.
59916
+*/
59917
+static void freeSuperJournal(char *zSuper){
59918
+ if( zSuper ){
59919
+ sqlite3_free(&zSuper[-4]);
59920
+ }
59921
+}
59922
+
59923
+/*
59924
+** Parameter pJrnl is a file-handle open on a journal file. This function
59925
+** attempts to read a super-journal file name from the end of the journal
59926
+** file. If successful, it sets output parameter (*pzSuper) to point to a
59927
+** buffer containing the super-journal name as a nul-terminated string.
59928
+** The caller is responsible for freeing the buffer using freeSuperJournal().
59929
+**
59930
+** Refer to comments above writeSuperJournal() for the format used to store
59931
+** a super-journal file name at the end of a journal file.
59932
+**
59933
+** Parameter nSuper is passed the maximum allowable size of the super journal
59934
+** name in bytes. If the super-journal name in the journal is longer than
59935
+** nSuper bytes (including a nul-terminator), then this is handled as if no
59936
+** super-journal name were present in the journal.
59937
+**
59938
+** If there is no super-journal name at the end of pJrnl, (*pzSuper) is
59939
+** set to 0 and SQLITE_OK is returned. Or, if an error occurs while reading
59940
+** the super-journal name, an SQLite error code is returned and (*pzSuper)
59941
+** is set to 0.
59942
+*/
59943
+static int readSuperJournal(sqlite3_file *pJrnl, u64 nSuper, char **pzSuper){
5986959944
int rc; /* Return code */
5987059945
u32 len; /* Length in bytes of super-journal name */
5987159946
i64 szJ; /* Total size in bytes of journal file pJrnl */
5987259947
u32 cksum; /* MJ checksum value read from journal */
59873
- u32 u; /* Unsigned loop counter */
5987459948
unsigned char aMagic[8]; /* A buffer to hold the magic header */
59875
- zSuper[0] = '\0';
59949
+ char *zOut = 0;
5987659950
59951
+ *pzSuper = 0;
5987759952
if( SQLITE_OK!=(rc = sqlite3OsFileSize(pJrnl, &szJ))
5987859953
|| szJ<16
5987959954
|| SQLITE_OK!=(rc = read32bits(pJrnl, szJ-16, &len))
5988059955
|| len>=nSuper
5988159956
|| len>szJ-16
5988259957
|| len==0
5988359958
|| SQLITE_OK!=(rc = read32bits(pJrnl, szJ-12, &cksum))
5988459959
|| SQLITE_OK!=(rc = sqlite3OsRead(pJrnl, aMagic, 8, szJ-8))
5988559960
|| memcmp(aMagic, aJournalMagic, 8)
59886
- || SQLITE_OK!=(rc = sqlite3OsRead(pJrnl, zSuper, len, szJ-16-len))
5988759961
){
5988859962
return rc;
5988959963
}
5989059964
59891
- /* See if the checksum matches the super-journal name */
59892
- for(u=0; u<len; u++){
59893
- cksum -= zSuper[u];
59894
- }
59895
- if( cksum ){
59896
- /* If the checksum doesn't add up, then one or more of the disk sectors
59897
- ** containing the super-journal filename is corrupted. This means
59898
- ** definitely roll back, so just return SQLITE_OK and report a (nul)
59899
- ** super-journal filename.
59900
- */
59901
- len = 0;
59902
- }
59903
- zSuper[len] = '\0';
59904
- zSuper[len+1] = '\0';
59905
-
59906
- return SQLITE_OK;
59965
+ zOut = (char*)sqlite3MallocZero(4 + len + 2);
59966
+ if( !zOut ){
59967
+ rc = SQLITE_NOMEM_BKPT;
59968
+ }else{
59969
+ zOut = &zOut[4];
59970
+ if( SQLITE_OK==(rc = sqlite3OsRead(pJrnl, zOut, len, szJ-16-len)) ){
59971
+ u32 u; /* Unsigned loop counter */
59972
+ /* See if the checksum matches the super-journal name */
59973
+ for(u=0; u<len; u++){
59974
+ cksum -= zOut[u];
59975
+ }
59976
+ }
59977
+ if( rc!=SQLITE_OK || cksum ){
59978
+ /* If the checksum doesn't add up, then one or more of the disk sectors
59979
+ ** containing the super-journal filename is corrupted. This means
59980
+ ** definitely roll back, so just return SQLITE_OK and report a (nul)
59981
+ ** super-journal filename. */
59982
+ freeSuperJournal(zOut);
59983
+ zOut = 0;
59984
+ }
59985
+ }
59986
+
59987
+ *pzSuper = zOut;
59988
+ return rc;
5990759989
}
5990859990
5990959991
/*
5991059992
** Return the offset of the sector boundary at or immediately
5991159993
** following the value in pPager->journalOff, assuming a sector
@@ -61106,13 +61188,11 @@
6110661188
sqlite3_file *pSuper; /* Malloc'd super-journal file descriptor */
6110761189
sqlite3_file *pJournal; /* Malloc'd child-journal file descriptor */
6110861190
char *zSuperJournal = 0; /* Contents of super-journal file */
6110961191
i64 nSuperJournal; /* Size of super-journal file */
6111061192
char *zJournal; /* Pointer to one journal within MJ file */
61111
- char *zSuperPtr; /* Space to hold super-journal filename */
6111261193
char *zFree = 0; /* Free this buffer */
61113
- i64 nSuperPtr; /* Amount of space allocated to zSuperPtr[] */
6111461194
6111561195
/* Allocate space for both the pJournal and pSuper file descriptors.
6111661196
** If successful, open the super-journal file for reading.
6111761197
*/
6111861198
pSuper = (sqlite3_file *)sqlite3MallocZero(2 * (i64)pVfs->szOsFile);
@@ -61131,22 +61211,20 @@
6113161211
** sufficient space (in zSuperPtr) to hold the names of super-journal
6113261212
** files extracted from regular rollback-journals.
6113361213
*/
6113461214
rc = sqlite3OsFileSize(pSuper, &nSuperJournal);
6113561215
if( rc!=SQLITE_OK ) goto delsuper_out;
61136
- nSuperPtr = 1 + (i64)pVfs->mxPathname;
61137
- assert( nSuperJournal>=0 && nSuperPtr>0 );
61138
- zFree = sqlite3Malloc(4 + nSuperJournal + 2 + nSuperPtr + 2);
61216
+ assert( nSuperJournal>=0 );
61217
+ zFree = sqlite3Malloc(4 + nSuperJournal + 2);
6113961218
if( !zFree ){
6114061219
rc = SQLITE_NOMEM_BKPT;
6114161220
goto delsuper_out;
6114261221
}else{
6114361222
assert( nSuperJournal<=0x7fffffff );
6114461223
}
6114561224
zFree[0] = zFree[1] = zFree[2] = zFree[3] = 0;
6114661225
zSuperJournal = &zFree[4];
61147
- zSuperPtr = &zSuperJournal[nSuperJournal+2];
6114861226
rc = sqlite3OsRead(pSuper, zSuperJournal, (int)nSuperJournal, 0);
6114961227
if( rc!=SQLITE_OK ) goto delsuper_out;
6115061228
zSuperJournal[nSuperJournal] = 0;
6115161229
zSuperJournal[nSuperJournal+1] = 0;
6115261230
@@ -61156,10 +61234,12 @@
6115661234
rc = sqlite3OsAccess(pVfs, zJournal, SQLITE_ACCESS_EXISTS, &exists);
6115761235
if( rc!=SQLITE_OK ){
6115861236
goto delsuper_out;
6115961237
}
6116061238
if( exists ){
61239
+ char *zSuperPtr = 0;
61240
+
6116161241
/* One of the journals pointed to by the super-journal exists.
6116261242
** Open it and check if it points at the super-journal. If
6116361243
** so, return without deleting the super-journal file.
6116461244
** NB: zJournal is really a MAIN_JOURNAL. But call it a
6116561245
** SUPER_JOURNAL here so that the VFS will not send the zJournal
@@ -61170,17 +61250,19 @@
6117061250
rc = sqlite3OsOpen(pVfs, zJournal, pJournal, flags, 0);
6117161251
if( rc!=SQLITE_OK ){
6117261252
goto delsuper_out;
6117361253
}
6117461254
61175
- rc = readSuperJournal(pJournal, zSuperPtr, nSuperPtr);
61255
+ rc = readSuperJournal(pJournal, 1+(u64)pVfs->mxPathname, &zSuperPtr);
6117661256
sqlite3OsClose(pJournal);
6117761257
if( rc!=SQLITE_OK ){
61258
+ assert( zSuperPtr==0 );
6117861259
goto delsuper_out;
6117961260
}
6118061261
61181
- c = zSuperPtr[0]!=0 && strcmp(zSuperPtr, zSuper)==0;
61262
+ c = zSuperPtr!=0 && strcmp(zSuperPtr, zSuper)==0;
61263
+ freeSuperJournal(zSuperPtr);
6118261264
if( c ){
6118361265
/* We have a match. Do not delete the super-journal file. */
6118461266
goto delsuper_out;
6118561267
}
6118661268
}
@@ -61391,23 +61473,15 @@
6139161473
6139261474
/* Read the super-journal name from the journal, if it is present.
6139361475
** If a super-journal file name is specified, but the file is not
6139461476
** present on disk, then the journal is not hot and does not need to be
6139561477
** played back.
61396
- **
61397
- ** TODO: Technically the following is an error because it assumes that
61398
- ** buffer Pager.pTmpSpace is (mxPathname+1) bytes or larger. i.e. that
61399
- ** ((pPager->pageSize+8) >= pPager->pVfs->mxPathname+1). Using os_unix.c,
61400
- ** mxPathname is 512, which is the same as the minimum allowable value
61401
- ** for pageSize, and so this assumption holds. But it might not for some
61402
- ** custom VFS. */
61403
- zSuper = pPager->pTmpSpace;
61404
- rc = readSuperJournal(pPager->jfd, zSuper, 1+(i64)pPager->pVfs->mxPathname);
61405
- if( rc==SQLITE_OK && zSuper[0] ){
61478
+ */
61479
+ rc = readSuperJournal(pPager->jfd, 1+(i64)pPager->pVfs->mxPathname, &zSuper);
61480
+ if( rc==SQLITE_OK && zSuper ){
6140661481
rc = sqlite3OsAccess(pVfs, zSuper, SQLITE_ACCESS_EXISTS, &res);
6140761482
}
61408
- zSuper = 0;
6140961483
if( rc!=SQLITE_OK || !res ){
6141061484
goto end_playback;
6141161485
}
6141261486
pPager->journalOff = 0;
6141361487
needPagerReset = isHot;
@@ -61532,34 +61606,24 @@
6153261606
** problems for other processes at some point in the future. So, just
6153361607
** in case this has happened, clear the changeCountDone flag now.
6153461608
*/
6153561609
pPager->changeCountDone = pPager->tempFile;
6153661610
61537
- if( rc==SQLITE_OK ){
61538
- /* Leave 4 bytes of space before the super-journal filename in memory.
61539
- ** This is because it may end up being passed to sqlite3OsOpen(), in
61540
- ** which case it requires 4 0x00 bytes in memory immediately before
61541
- ** the filename. */
61542
- zSuper = &pPager->pTmpSpace[4];
61543
- rc = readSuperJournal(pPager->jfd, zSuper, 1+(i64)pPager->pVfs->mxPathname);
61544
- testcase( rc!=SQLITE_OK );
61545
- }
6154661611
if( rc==SQLITE_OK
6154761612
&& (pPager->eState>=PAGER_WRITER_DBMOD || pPager->eState==PAGER_OPEN)
6154861613
){
6154961614
rc = sqlite3PagerSync(pPager, 0);
6155061615
}
6155161616
if( rc==SQLITE_OK ){
61552
- rc = pager_end_transaction(pPager, zSuper[0]!='\0', 0);
61617
+ rc = pager_end_transaction(pPager, zSuper!=0, 0);
6155361618
testcase( rc!=SQLITE_OK );
6155461619
}
61555
- if( rc==SQLITE_OK && zSuper[0] && res ){
61620
+ if( rc==SQLITE_OK && zSuper && res ){
6155661621
/* If there was a super-journal and this routine will return success,
6155761622
** see if it is possible to delete the super-journal.
6155861623
*/
61559
- assert( zSuper==&pPager->pTmpSpace[4] );
61560
- memset(pPager->pTmpSpace, 0, 4);
61624
+ assert( memcmp(&zSuper[-4], "\0\0\0\0", 4)==0 );
6156161625
rc = pager_delsuper(pPager, zSuper);
6156261626
testcase( rc!=SQLITE_OK );
6156361627
}
6156461628
if( isHot && nPlayback ){
6156561629
sqlite3_log(SQLITE_NOTICE_RECOVER_ROLLBACK, "recovered %d pages from %s",
@@ -61568,10 +61632,11 @@
6156861632
6156961633
/* The Pager.sectorSize variable may have been updated while rolling
6157061634
** back a journal created by a process with a different sector size
6157161635
** value. Reset it to the correct value for this process.
6157261636
*/
61637
+ freeSuperJournal(zSuper);
6157361638
setSectorSize(pPager);
6157461639
return rc;
6157561640
}
6157661641
6157761642
@@ -67426,10 +67491,16 @@
6742667491
*/
6742767492
pgno = sqlite3Get4byte(&aFrame[0]);
6742867493
if( pgno==0 ){
6742967494
return 0;
6743067495
}
67496
+
67497
+ /* Need a valid page size
67498
+ */
67499
+ if( !pWal->szPage ){
67500
+ return 0;
67501
+ }
6743167502
6743267503
/* A frame is only valid if a checksum of the WAL header,
6743367504
** all prior frames, the first 16 bytes of this frame-header,
6743467505
** and the frame-data matches the checksum in the last 8
6743567506
** bytes of this frame-header.
@@ -69281,11 +69352,11 @@
6928169352
goto begin_unreliable_shm_out;
6928269353
}
6928369354
6928469355
/* Allocate a buffer to read frames into */
6928569356
assert( (pWal->szPage & (pWal->szPage-1))==0 );
69286
- assert( pWal->szPage>=512 && pWal->szPage<=65536 );
69357
+ assert( (pWal->szPage>=512 && pWal->szPage<=65536) || pWal->szPage==0 );
6928769358
szFrame = pWal->szPage + WAL_FRAME_HDRSIZE;
6928869359
aFrame = (u8 *)sqlite3_malloc64(szFrame);
6928969360
if( aFrame==0 ){
6929069361
rc = SQLITE_NOMEM_BKPT;
6929169362
goto begin_unreliable_shm_out;
@@ -83086,10 +83157,15 @@
8308683157
if( pc+info.nSize>usableSize ){
8308783158
checkAppendMsg(pCheck, "Extends off end of page");
8308883159
doCoverageCheck = 0;
8308983160
continue;
8309083161
}
83162
+ if( info.nPayload && info.pPayload[0]<2 ){
83163
+ checkAppendMsg(pCheck, "Bad cell header size");
83164
+ doCoverageCheck = 0;
83165
+ continue;
83166
+ }
8309183167
8309283168
/* Check for integer primary key out of range */
8309383169
if( pPage->intKey ){
8309483170
if( keyCanBeEqual ? (info.nKey > maxKey) : (info.nKey >= maxKey) ){
8309583171
checkAppendMsg(pCheck, "Rowid %lld out of order", info.nKey);
@@ -84596,12 +84672,12 @@
8459684672
/* Work-around for GCC bug or bugs:
8459784673
** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=96270
8459884674
** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=114659
8459984675
** The problem appears to be fixed in GCC 15 */
8460084676
i64 x;
84601
- assert( (MEM_Str&~p->flags)*4==sizeof(x) );
84602
- memcpy(&x, (char*)&p->u, (MEM_Str&~p->flags)*4);
84677
+ assert( (sqlite3Config.bSmallMalloc!=0xee)*8==sizeof(x) );
84678
+ memcpy(&x, (char*)&p->u.i, (sqlite3Config.bSmallMalloc!=0xee)*8);
8460384679
p->n = sqlite3Int64ToText(x, zBuf);
8460484680
#else
8460584681
p->n = sqlite3Int64ToText(p->u.i, zBuf);
8460684682
#endif
8460784683
if( p->flags & MEM_IntReal ){
@@ -95448,11 +95524,11 @@
9544895524
QueryPerformanceCounter(&tm);
9544995525
return (sqlite3_uint64)tm.QuadPart;
9545095526
}
9545195527
9545295528
#elif !defined(__STRICT_ANSI__) && defined(__GNUC__) && \
95453
- (defined(i386) || defined(__i386__) || defined(_M_IX86))
95529
+ (defined(i586) || defined(__i586__) || defined(_M_IX86))
9545495530
9545595531
__inline__ sqlite_uint64 sqlite3Hwtime(void){
9545695532
unsigned int lo, hi;
9545795533
__asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
9545895534
return (sqlite_uint64)hi << 32 | lo;
@@ -103037,11 +103113,11 @@
103037103113
if( pFrame ) break;
103038103114
}
103039103115
103040103116
if( p->nFrame>=db->aLimit[SQLITE_LIMIT_TRIGGER_DEPTH] ){
103041103117
rc = SQLITE_ERROR;
103042
- sqlite3VdbeError(p, "too many levels of trigger recursion");
103118
+ sqlite3VdbeError(p, "triggers nested too deep");
103043103119
goto abort_due_to_error;
103044103120
}
103045103121
103046103122
/* Register pRt is used to store the memory required to save the state
103047103123
** of the current program, and the memory required at runtime to execute
@@ -110164,11 +110240,11 @@
110164110240
/*
110165110241
** cnt==0 means there was not match.
110166110242
** cnt>1 means there were two or more matches.
110167110243
**
110168110244
** cnt==0 is always an error. cnt>1 is often an error, but might
110169
- ** be multiple matches for a NATURAL LEFT JOIN or a LEFT JOIN USING.
110245
+ ** be multiple matches for a NATURAL OUTER JOIN or a OUTER JOIN USING.
110170110246
*/
110171110247
assert( pFJMatch==0 || cnt>0 );
110172110248
assert( !ExprHasProperty(pExpr, EP_xIsSelect|EP_IntValue) );
110173110249
if( cnt!=1 ){
110174110250
const char *zErr;
@@ -110247,12 +110323,21 @@
110247110323
pExpr->op = eNewExprOp;
110248110324
lookupname_end:
110249110325
if( cnt==1 ){
110250110326
assert( pNC!=0 );
110251110327
#ifndef SQLITE_OMIT_AUTHORIZATION
110252
- if( db->xAuth && (pExpr->op==TK_COLUMN || pExpr->op==TK_TRIGGER) ){
110253
- sqlite3AuthRead(pParse, pExpr, pSchema, pNC->pSrcList);
110328
+ if( db->xAuth ){
110329
+ if( pFJMatch ){
110330
+ assert( pExpr->op==TK_FUNCTION );
110331
+ assert( sqlite3_stricmp(pExpr->u.zToken,"coalesce")==0 );
110332
+ assert( pExpr->x.pList==pFJMatch );
110333
+ assert( pFJMatch->nExpr>0 );
110334
+ pExpr = pFJMatch->a[0].pExpr;
110335
+ }
110336
+ if( pExpr->op==TK_COLUMN || pExpr->op==TK_TRIGGER ){
110337
+ sqlite3AuthRead(pParse, pExpr, pSchema, pNC->pSrcList);
110338
+ }
110254110339
}
110255110340
#endif
110256110341
/* Increment the nRef value on all name contexts from TopNC up to
110257110342
** the point where the name matched. */
110258110343
for(;;){
@@ -115930,10 +116015,11 @@
115930116015
if( i!=nVector ){
115931116016
/* Need to reorder the LHS fields according to aiMap */
115932116017
int rLhsOrig = rLhs;
115933116018
rLhs = sqlite3GetTempRange(pParse, nVector);
115934116019
for(i=0; i<nVector; i++){
116020
+ testcase( aiMap[i]!=i );
115935116021
sqlite3VdbeAddOp3(v, OP_Copy, rLhsOrig+i, rLhs+aiMap[i], 0);
115936116022
}
115937116023
sqlite3ReleaseTempReg(pParse, rLhsOrig);
115938116024
}
115939116025
}
@@ -115950,11 +116036,12 @@
115950116036
}
115951116037
for(i=0; i<nVector; i++){
115952116038
Expr *p = sqlite3VectorFieldSubexpr(pExpr->pLeft, i);
115953116039
if( pParse->nErr ) goto sqlite3ExprCodeIN_oom_error;
115954116040
if( sqlite3ExprCanBeNull(p) ){
115955
- sqlite3VdbeAddOp2(v, OP_IsNull, rLhs+i, destStep2);
116041
+ testcase( aiMap[i]!=i );
116042
+ sqlite3VdbeAddOp2(v, OP_IsNull, rLhs+aiMap[i], destStep2);
115956116043
VdbeCoverage(v);
115957116044
}
115958116045
}
115959116046
115960116047
/* Step 3. The LHS is now known to be non-NULL. Do the binary search
@@ -116024,13 +116111,23 @@
116024116111
for(i=0; i<nVector; i++){
116025116112
Expr *p;
116026116113
CollSeq *pColl;
116027116114
int r3 = sqlite3GetTempReg(pParse);
116028116115
p = sqlite3VectorFieldSubexpr(pLeft, i);
116029
- pColl = sqlite3ExprCollSeq(pParse, p);
116030
- sqlite3VdbeAddOp3(v, OP_Column, iTab, i, r3);
116031
- sqlite3VdbeAddOp4(v, OP_Ne, rLhs+i, destNotNull, r3,
116116
+ if( ExprUseXSelect(pExpr) ){
116117
+ Expr *pRhs = pExpr->x.pSelect->pEList->a[i].pExpr;
116118
+ pColl = sqlite3BinaryCompareCollSeq(pParse, p, pRhs);
116119
+ }else{
116120
+ /* If the RHS of the IN(...) expression are scalar expressions, do
116121
+ ** not consider their collation sequences. The documentation says
116122
+ ** "The collating sequence used for expressions of the form "x IN (y, z,
116123
+ ** ...)" is the collating sequence of x.". */
116124
+ pColl = sqlite3ExprCollSeq(pParse, p);
116125
+ }
116126
+ testcase( aiMap[i]!=i );
116127
+ sqlite3VdbeAddOp3(v, OP_Column, iTab, aiMap[i], r3);
116128
+ sqlite3VdbeAddOp4(v, OP_Ne, rLhs+aiMap[i], destNotNull, r3,
116032116129
(void*)pColl, P4_COLLSEQ);
116033116130
VdbeCoverage(v);
116034116131
sqlite3ReleaseTempReg(pParse, r3);
116035116132
}
116036116133
sqlite3VdbeAddOp2(v, OP_Goto, 0, destIfNull);
@@ -124365,21 +124462,21 @@
124365124462
}else{
124366124463
nIdxCol = pIdx->nColumn;
124367124464
}
124368124465
pIdx->nSampleCol = nIdxCol;
124369124466
pIdx->mxSample = nSample;
124370
- nByte = ROUND8(sizeof(IndexSample) * nSample);
124371
- nByte += sizeof(tRowcnt) * nIdxCol * 3 * nSample;
124372
- nByte += nIdxCol * sizeof(tRowcnt); /* Space for Index.aAvgEq[] */
124467
+ nByte = ROUND8(sizeof64(IndexSample) * nSample);
124468
+ nByte += sizeof64(tRowcnt) * nIdxCol * 3 * nSample;
124469
+ nByte += nIdxCol * sizeof64(tRowcnt); /* Space for Index.aAvgEq[] */
124373124470
124374124471
pIdx->aSample = sqlite3DbMallocZero(db, nByte);
124375124472
if( pIdx->aSample==0 ){
124376124473
sqlite3_finalize(pStmt);
124377124474
return SQLITE_NOMEM_BKPT;
124378124475
}
124379124476
pPtr = (u8*)pIdx->aSample;
124380
- pPtr += ROUND8(nSample*sizeof(pIdx->aSample[0]));
124477
+ pPtr += ROUND8(nSample*sizeof64(pIdx->aSample[0]));
124381124478
pSpace = (tRowcnt*)pPtr;
124382124479
assert( EIGHT_BYTE_ALIGNMENT( pSpace ) );
124383124480
pIdx->aAvgEq = pSpace; pSpace += nIdxCol;
124384124481
pIdx->pTable->tabFlags |= TF_HasStat4;
124385124482
for(i=0; i<nSample; i++){
@@ -133231,13 +133328,22 @@
133231133328
x.nUsed = 0;
133232133329
x.apArg = argv+1;
133233133330
sqlite3StrAccumInit(&str, db, 0, 0, db->aLimit[SQLITE_LIMIT_LENGTH]);
133234133331
str.printfFlags = SQLITE_PRINTF_SQLFUNC;
133235133332
sqlite3_str_appendf(&str, zFormat, &x);
133236
- n = str.nChar;
133237
- sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n,
133238
- SQLITE_DYNAMIC);
133333
+ if( str.accError==SQLITE_OK ){
133334
+ n = str.nChar;
133335
+ sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n,
133336
+ SQLITE_DYNAMIC);
133337
+ }else{
133338
+ if( str.accError==SQLITE_NOMEM ){
133339
+ sqlite3_result_error_nomem(context);
133340
+ }else{
133341
+ sqlite3_result_error_toobig(context);
133342
+ }
133343
+ sqlite3_str_reset(&str);
133344
+ }
133239133345
}
133240133346
}
133241133347
133242133348
/*
133243133349
** Implementation of the substr() function.
@@ -134405,11 +134511,11 @@
134405134511
int nStr; /* Size of zStr */
134406134512
int nPattern; /* Size of zPattern */
134407134513
int nRep; /* Size of zRep */
134408134514
i64 nOut; /* Maximum size of zOut */
134409134515
int loopLimit; /* Last zStr[] that might match zPattern[] */
134410
- int i, j; /* Loop counters */
134516
+ i64 i, j; /* Loop counters */
134411134517
unsigned cntExpand; /* Number zOut expansions */
134412134518
sqlite3 *db = sqlite3_context_db_handle(context);
134413134519
134414134520
assert( argc==3 );
134415134521
UNUSED_PARAMETER(argc);
@@ -135859,57 +135965,95 @@
135859135965
** from the standard library because:
135860135966
**
135861135967
** (1) To avoid a dependency on qsort()
135862135968
** (2) To avoid the function call to the comparison routine for each
135863135969
** comparison.
135970
+**
135971
+** If parameter iReq is non-negative, then the caller will only access
135972
+** elements a[iReq] and a[iReq+1] (if it exists) of the sorted array and
135973
+** so it is not necessary to position any other elements. Or if iReq is
135974
+** negative, then the final array must be fully sorted.
135864135975
*/
135865
-static void percentSort(double *a, unsigned int n){
135976
+static void percentSort(
135977
+ double *a, /* Array to sort */
135978
+ unsigned int n, /* Number of elements in array a[] */
135979
+ int iReq /* Element caller cares about (or -ve) */
135980
+){
135866135981
int iLt; /* Entries before a[iLt] are less than rPivot */
135867135982
int iGt; /* Entries at or after a[iGt] are greater than rPivot */
135868135983
int i; /* Loop counter */
135869135984
double rPivot; /* The pivot value */
135870135985
135871135986
assert( n>=2 );
135872
- if( a[0]>a[n-1] ){
135873
- SWAP_DOUBLE(a[0],a[n-1])
135874
- }
135875
- if( n==2 ) return;
135876
- iGt = n-1;
135877
- i = n/2;
135878
- if( a[0]>a[i] ){
135879
- SWAP_DOUBLE(a[0],a[i])
135880
- }else if( a[i]>a[iGt] ){
135881
- SWAP_DOUBLE(a[i],a[iGt])
135882
- }
135883
- if( n==3 ) return;
135884
- rPivot = a[i];
135885
- iLt = i = 1;
135886
- do{
135887
- if( a[i]<rPivot ){
135888
- if( i>iLt ) SWAP_DOUBLE(a[i],a[iLt])
135889
- iLt++;
135890
- i++;
135891
- }else if( a[i]>rPivot ){
135892
- do{
135893
- iGt--;
135894
- }while( iGt>i && a[iGt]>rPivot );
135895
- SWAP_DOUBLE(a[i],a[iGt])
135896
- }else{
135897
- i++;
135898
- }
135899
- }while( i<iGt );
135900
- if( iLt>=2 ) percentSort(a, iLt);
135901
- if( n-iGt>=2 ) percentSort(a+iGt, n-iGt);
135902
-
135903
-/* Uncomment for testing */
135904
-#if 0
135905
- for(i=0; i<n-1; i++){
135906
- assert( a[i]<=a[i+1] );
135907
- }
135908
-#endif
135909
-}
135910
-
135987
+ do{
135988
+ if( a[0]>a[n-1] ){
135989
+ SWAP_DOUBLE(a[0],a[n-1])
135990
+ }
135991
+ if( n==2 ) return;
135992
+ iGt = n-1;
135993
+ i = n/2;
135994
+ if( a[0]>a[i] ){
135995
+ SWAP_DOUBLE(a[0],a[i])
135996
+ }else if( a[i]>a[iGt] ){
135997
+ SWAP_DOUBLE(a[i],a[iGt])
135998
+ }
135999
+ if( n==3 ) return;
136000
+ rPivot = a[i];
136001
+ iLt = i = 1;
136002
+ do{
136003
+ if( a[i]<rPivot ){
136004
+ if( i>iLt ) SWAP_DOUBLE(a[i],a[iLt])
136005
+ iLt++;
136006
+ i++;
136007
+ }else if( a[i]>rPivot ){
136008
+ do{
136009
+ iGt--;
136010
+ }while( iGt>i && a[iGt]>rPivot );
136011
+ SWAP_DOUBLE(a[i],a[iGt])
136012
+ }else{
136013
+ i++;
136014
+ }
136015
+ }while( i<iGt );
136016
+
136017
+ assert( a[iLt]==rPivot );
136018
+ assert( iGt>iLt );
136019
+
136020
+ if( iReq>=0 ){
136021
+ /* In this case, the only elements that the caller requires sorted into
136022
+ ** the correct positions are elements a[iReq] and a[iReq+1]. At this
136023
+ ** point we know that element a[iLt] is in the correct position and
136024
+ ** all elements smaller than a[iLt] are in the left-hand partition.
136025
+ ** So if (iReq<iLt), then it is only necessary to sort the left
136026
+ ** partition.
136027
+ **
136028
+ ** If (iReq>=iLt), then elements iReq and iReq+1 are either in the
136029
+ ** right partition or the equal partition (elements for which
136030
+ ** iLt<=iElem<iGt). Therefore it is always sufficient to sort only
136031
+ ** the right partition in this case. */
136032
+ if( iReq<iLt ){
136033
+ n = iLt;
136034
+ }else{
136035
+ a += iGt;
136036
+ n -= iGt;
136037
+ iReq = MAX(0, iReq-iGt);
136038
+ }
136039
+ }else{
136040
+ /* Recurse on the smaller partition only. The smaller partition
136041
+ ** will hold n/2 or fewer entries, which assures that the stack
136042
+ ** depth will not exceed O(log(n)), even for pathological cases.
136043
+ ** Loop without recursion for the larger partition. */
136044
+ if( iLt>(int)(n/2) ){
136045
+ if( n-iGt>=2 ) percentSort(a+iGt, n-iGt, -1);
136046
+ n = iLt;
136047
+ }else{
136048
+ if( iLt>=2 ) percentSort(a, iLt, -1);
136049
+ a += iGt;
136050
+ n -= iGt;
136051
+ }
136052
+ }
136053
+ }while( n>=2 );
136054
+}
135911136055
135912136056
/*
135913136057
** The "inverse" function for percentile(Y,P) is called to remove a
135914136058
** row that was previously inserted by "step".
135915136059
*/
@@ -135939,11 +136083,11 @@
135939136083
if( percentIsInfinity(y) ){
135940136084
return;
135941136085
}
135942136086
if( p->bSorted==0 ){
135943136087
assert( p->nUsed>1 );
135944
- percentSort(p->a, p->nUsed);
136088
+ percentSort(p->a, p->nUsed, -1);
135945136089
p->bSorted = 1;
135946136090
}
135947136091
p->bKeepSorted = 1;
135948136092
135949136093
/* Find and remove the row */
@@ -135968,17 +136112,21 @@
135968136112
double ix, vx;
135969136113
p = (Percentile*)sqlite3_aggregate_context(pCtx, 0);
135970136114
if( p==0 ) return;
135971136115
if( p->a==0 ) return;
135972136116
if( p->nUsed ){
136117
+ ix = p->rPct*(p->nUsed-1);
136118
+ i1 = (unsigned)ix;
135973136119
if( p->bSorted==0 ){
136120
+ /* In cases where bIsFinal is non-zero, setting Percentile.bSorted
136121
+ ** after the percentSort() call here is not technically correct, as
136122
+ ** the array is not fully sorted. But in this case the object will be
136123
+ ** freed below anyway, so it doesn't matter. */
135974136124
assert( p->nUsed>1 );
135975
- percentSort(p->a, p->nUsed);
136125
+ percentSort(p->a, p->nUsed, (bIsFinal ? (int)i1 : -1));
135976136126
p->bSorted = 1;
135977136127
}
135978
- ix = p->rPct*(p->nUsed-1);
135979
- i1 = (unsigned)ix;
135980136128
if( settings & 1 ){
135981136129
vx = p->a[i1];
135982136130
}else{
135983136131
i2 = ix==(double)i1 || i1==p->nUsed-1 ? i1 : i1+1;
135984136132
v1 = p->a[i1];
@@ -150488,10 +150636,17 @@
150488150636
SQLITE_PRIVATE Table *sqlite3ResultSetOfSelect(Parse *pParse, Select *pSelect, char aff){
150489150637
Table *pTab;
150490150638
sqlite3 *db = pParse->db;
150491150639
u64 savedFlags;
150492150640
150641
+ pParse->nNestSel++;
150642
+#if SQLITE_MAX_EXPR_DEPTH>0
150643
+ if( pParse->nNestSel >= db->aLimit[SQLITE_LIMIT_EXPR_DEPTH] ){
150644
+ sqlite3ErrorMsg(pParse, "VIEWs and/or subqueries nested too deep");
150645
+ return 0;
150646
+ }
150647
+#endif
150493150648
savedFlags = db->flags;
150494150649
db->flags &= ~(u64)SQLITE_FullColNames;
150495150650
db->flags |= SQLITE_ShortColNames;
150496150651
sqlite3SelectPrep(pParse, pSelect, 0);
150497150652
db->flags = savedFlags;
@@ -150509,10 +150664,12 @@
150509150664
pTab->iPKey = -1;
150510150665
if( db->mallocFailed ){
150511150666
sqlite3DeleteTable(db, pTab);
150512150667
return 0;
150513150668
}
150669
+ pParse->nNestSel--;
150670
+ assert( pParse->nNestSel>=0 );
150514150671
return pTab;
150515150672
}
150516150673
150517150674
/*
150518150675
** Get a VDBE for the given parser context. Create a new one if necessary.
@@ -155479,12 +155636,15 @@
155479155636
** Then, if CheckOnCtx.iJoin indicates that this expression is part of an
155480155637
** ON clause from that SrcList (i.e. if iJoin is non-zero), check that it
155481155638
** does not refer to a table to the right of CheckOnCtx.iJoin. */
155482155639
do {
155483155640
SrcList *pSrc = pCtx->pSrc;
155641
+ int nSrc = pSrc->nSrc;
155484155642
int iTab = pExpr->iTable;
155485
- if( iTab>=pSrc->a[0].iCursor && iTab<=pSrc->a[pSrc->nSrc-1].iCursor ){
155643
+ int ii;
155644
+ for(ii=0; ii<nSrc && pSrc->a[ii].iCursor!=iTab; ii++){}
155645
+ if( ii<nSrc ){
155486155646
if( pCtx->iJoin && iTab>pCtx->iJoin ){
155487155647
sqlite3ErrorMsg(pWalker->pParse,
155488155648
"%s references tables to its right",
155489155649
(pCtx->bFuncArg ? "table-function argument" : "ON clause")
155490155650
);
@@ -158449,22 +158609,36 @@
158449158609
Parse *pParse, /* Current parse context */
158450158610
Trigger *pTrigger, /* Trigger to code */
158451158611
Table *pTab, /* The table pTrigger is attached to */
158452158612
int orconf /* ON CONFLICT policy to code trigger program with */
158453158613
){
158454
- Parse *pTop = sqlite3ParseToplevel(pParse);
158614
+ Parse *pTop; /* Top level Parse object */
158455158615
sqlite3 *db = pParse->db; /* Database handle */
158456158616
TriggerPrg *pPrg; /* Value to return */
158457158617
Expr *pWhen = 0; /* Duplicate of trigger WHEN expression */
158458158618
Vdbe *v; /* Temporary VM */
158459158619
NameContext sNC; /* Name context for sub-vdbe */
158460158620
SubProgram *pProgram = 0; /* Sub-vdbe for trigger program */
158461158621
int iEndTrigger = 0; /* Label to jump to if WHEN is false */
158462158622
Parse sSubParse; /* Parse context for sub-vdbe */
158623
+ int nDepth; /* Trigger depth */
158463158624
158625
+ /* Ensure that triggers are not chained too deep. This test is linear
158626
+ ** in the chaining depth, but sensible code ought not be chaining
158627
+ ** triggers excessively, so that shouldn't be a problem.
158628
+ */
158629
+ pTop = pParse;
158630
+ for(nDepth=0; pTop->pOuterParse; pTop = pTop->pOuterParse, nDepth++){}
158631
+ if( nDepth>=db->aLimit[SQLITE_LIMIT_TRIGGER_DEPTH] ){
158632
+ sqlite3ErrorMsg(pParse, "triggers nested too deep");
158633
+ return 0;
158634
+ }
158635
+
158636
+ pTop = sqlite3ParseToplevel(pParse);
158464158637
assert( pTrigger->zName==0 || pTab==tableOfTrigger(pTrigger) );
158465158638
assert( pTop->pVdbe );
158639
+
158466158640
158467158641
/* Allocate the TriggerPrg and SubProgram objects. To ensure that they
158468158642
** are freed if an error occurs, link them into the Parse.pTriggerPrg
158469158643
** list of the top-level Parse object sooner rather than later. */
158470158644
pPrg = sqlite3DbMallocZero(db, sizeof(TriggerPrg));
@@ -160462,11 +160636,12 @@
160462160636
** So we have to make a copy before passing it down into sqlite3Update() */
160463160637
pSrc = sqlite3SrcListDup(db, pTop->pUpsertSrc, 0);
160464160638
/* excluded.* columns of type REAL need to be converted to a hard real */
160465160639
for(i=0; i<pTab->nCol; i++){
160466160640
if( pTab->aCol[i].affinity==SQLITE_AFF_REAL ){
160467
- sqlite3VdbeAddOp1(v, OP_RealAffinity, pTop->regData+i);
160641
+ int iStorage = pTop->regData + sqlite3TableColumnToStorage(pTab, i);
160642
+ sqlite3VdbeAddOp1(v, OP_RealAffinity, iStorage);
160468160643
}
160469160644
}
160470160645
sqlite3Update(pParse, pSrc, sqlite3ExprListDup(db,pUpsert->pUpsertSet,0),
160471160646
sqlite3ExprDup(db,pUpsert->pUpsertWhere,0), OE_Abort, 0, 0, pUpsert);
160472160647
VdbeNoopComment((v, "End DO UPDATE of UPSERT"));
@@ -165680,10 +165855,11 @@
165680165855
assert( (pTerm->eOperator & (WO_OR|WO_AND))==0 );
165681165856
pAlt = sqlite3WhereFindTerm(pWC, iCur, pTerm->u.x.leftColumn, notReady,
165682165857
WO_EQ|WO_IN|WO_IS, 0);
165683165858
if( pAlt==0 ) continue;
165684165859
if( pAlt->wtFlags & (TERM_CODED) ) continue;
165860
+ if( ExprHasProperty(pAlt->pExpr, EP_Collate) ) continue;
165685165861
if( (pAlt->eOperator & WO_IN)
165686165862
&& ExprUseXSelect(pAlt->pExpr)
165687165863
&& (pAlt->pExpr->x.pSelect->pEList->nExpr>1)
165688165864
){
165689165865
continue;
@@ -166433,11 +166609,14 @@
166433166609
** Mark term iChild as being a child of term iParent
166434166610
*/
166435166611
static void markTermAsChild(WhereClause *pWC, int iChild, int iParent){
166436166612
pWC->a[iChild].iParent = iParent;
166437166613
pWC->a[iChild].truthProb = pWC->a[iParent].truthProb;
166614
+ assert( pWC->a[iParent].nChild < UMXV(pWC->a[0].nChild) );
166438166615
pWC->a[iParent].nChild++;
166616
+ testcase( pWC->a[iParent].nChild == UMXV(pWC->a[0].nChild) );
166617
+
166439166618
}
166440166619
166441166620
/*
166442166621
** Return the N-th AND-connected subterm of pTerm. Or if pTerm is not
166443166622
** a conjunction, then return just pTerm when N==0. If N is exceeds
@@ -166872,21 +167051,22 @@
166872167051
** 1. The SQLITE_Transitive optimization must be enabled
166873167052
** 2. Must be either an == or an IS operator
166874167053
** 3. Not originating in the ON clause of an OUTER JOIN
166875167054
** 4. The operator is not IS or else the query does not contain RIGHT JOIN
166876167055
** 5. The affinities of A and B must be compatible
166877
-** 6. Both operands use the same collating sequence
167056
+** 6. Both operands use the same collating sequence, and they must not
167057
+** use explicit COLLATE clauses.
166878167058
** If this routine returns TRUE, that means that the RHS can be substituted
166879167059
** for the LHS anyplace else in the WHERE clause where the LHS column occurs.
166880167060
** This is an optimization. No harm comes from returning 0. But if 1 is
166881167061
** returned when it should not be, then incorrect answers might result.
166882167062
*/
166883167063
static int termIsEquivalence(Parse *pParse, Expr *pExpr, SrcList *pSrc){
166884167064
char aff1, aff2;
166885167065
if( !OptimizationEnabled(pParse->db, SQLITE_Transitive) ) return 0; /* (1) */
166886167066
if( pExpr->op!=TK_EQ && pExpr->op!=TK_IS ) return 0; /* (2) */
166887
- if( ExprHasProperty(pExpr, EP_OuterON) ) return 0; /* (3) */
167067
+ if( ExprHasProperty(pExpr, EP_OuterON|EP_Collate) ) return 0; /* (3) */
166888167068
assert( pSrc!=0 );
166889167069
if( pExpr->op==TK_IS
166890167070
&& pSrc->nSrc>=2
166891167071
&& (pSrc->a[0].fg.jointype & JT_LTORJ)!=0
166892167072
){
@@ -167209,10 +167389,11 @@
167209167389
static const u8 ops[] = {TK_GE, TK_LE};
167210167390
assert( ExprUseXList(pExpr) );
167211167391
pList = pExpr->x.pList;
167212167392
assert( pList!=0 );
167213167393
assert( pList->nExpr==2 );
167394
+ assert( pWC->a[idxTerm].nChild==0 );
167214167395
for(i=0; i<2; i++){
167215167396
Expr *pNewExpr;
167216167397
int idxNew;
167217167398
pNewExpr = sqlite3PExpr(pParse, ops[i],
167218167399
sqlite3ExprDup(db, pExpr->pLeft, 0),
@@ -167419,12 +167600,15 @@
167419167600
&& (pExpr->x.pSelect->pPrior==0 || (pExpr->x.pSelect->selFlags & SF_Values))
167420167601
#ifndef SQLITE_OMIT_WINDOWFUNC
167421167602
&& pExpr->x.pSelect->pWin==0
167422167603
#endif
167423167604
&& pWC->op==TK_AND
167605
+ && pExpr->x.pSelect->pEList->nExpr <= UMXV(pTerm->nChild)
167606
+ /* ^-- See bug 2026-06-04T10:00:49Z */
167424167607
){
167425167608
int i;
167609
+ assert( pTerm->nChild==0 );
167426167610
for(i=0; i<sqlite3ExprVectorSize(pExpr->pLeft); i++){
167427167611
int idxNew;
167428167612
idxNew = whereClauseInsert(pWC, pExpr, TERM_VIRTUAL|TERM_SLICE);
167429167613
pWC->a[idxNew].u.x.iField = i+1;
167430167614
exprAnalyze(pSrc, pWC, idxNew);
@@ -186978,11 +187162,11 @@
186978187162
** or disables the collection of memory allocation statistics. */
186979187163
sqlite3GlobalConfig.bMemstat = va_arg(ap, int);
186980187164
break;
186981187165
}
186982187166
case SQLITE_CONFIG_SMALL_MALLOC: {
186983
- sqlite3GlobalConfig.bSmallMalloc = va_arg(ap, int);
187167
+ sqlite3GlobalConfig.bSmallMalloc = va_arg(ap, int)!=0;
186984187168
break;
186985187169
}
186986187170
case SQLITE_CONFIG_PAGECACHE: {
186987187171
/* EVIDENCE-OF: R-18761-36601 There are three arguments to
186988187172
** SQLITE_CONFIG_PAGECACHE: A pointer to 8-byte aligned memory (pMem),
@@ -193287,10 +193471,16 @@
193287193471
#ifndef SQLITE_CORE
193288193472
/* # include "sqlite3ext.h" */
193289193473
SQLITE_EXTENSION_INIT1
193290193474
#endif
193291193475
193476
+
193477
+/*
193478
+** Assume any b-tree layer with more levels than this is corrupt.
193479
+*/
193480
+#define FTS3_MAX_BTREE_HEIGHT 48
193481
+
193292193482
typedef struct Fts3HashWrapper Fts3HashWrapper;
193293193483
struct Fts3HashWrapper {
193294193484
Fts3Hash hash; /* Hash table */
193295193485
int nRef; /* Number of pointers to this object */
193296193486
};
@@ -195003,11 +195193,15 @@
195003195193
int iHeight; /* Height of this node in tree */
195004195194
195005195195
assert( piLeaf || piLeaf2 );
195006195196
195007195197
fts3GetVarint32(zNode, &iHeight);
195008
- rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2);
195198
+ if( iHeight>FTS3_MAX_BTREE_HEIGHT ){
195199
+ rc = FTS_CORRUPT_VTAB;
195200
+ }else{
195201
+ rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2);
195202
+ }
195009195203
assert_fts3_nc( !piLeaf2 || !piLeaf || rc!=SQLITE_OK || (*piLeaf<=*piLeaf2) );
195010195204
195011195205
if( rc==SQLITE_OK && iHeight>1 ){
195012195206
char *zBlob = 0; /* Blob read from %_segments table */
195013195207
int nBlob = 0; /* Size of zBlob in bytes */
@@ -195048,12 +195242,17 @@
195048195242
char **pp, /* IN/OUT: Output pointer */
195049195243
sqlite3_int64 *piPrev, /* IN/OUT: Previous value written to list */
195050195244
sqlite3_int64 iVal /* Write this value to the list */
195051195245
){
195052195246
assert_fts3_nc( iVal-*piPrev > 0 || (*piPrev==0 && iVal==0) );
195053
- *pp += sqlite3Fts3PutVarint(*pp, iVal-*piPrev);
195054
- *piPrev = iVal;
195247
+ if( iVal-(*piPrev)>=0 ){
195248
+ /* Refuse to write a negative delta integer. This only happens with a
195249
+ ** corrupt db (see the assert above) and can cause buffer overwrites
195250
+ ** in some cases. */
195251
+ *pp += sqlite3Fts3PutVarint(*pp, iVal-*piPrev);
195252
+ *piPrev = iVal;
195253
+ }
195055195254
}
195056195255
195057195256
/*
195058195257
** When this function is called, *ppPoslist is assumed to point to the
195059195258
** start of a position-list. After it returns, *ppPoslist points to the
@@ -199532,11 +199731,11 @@
199532199731
break;
199533199732
199534199733
/* State 3. The integer just read is a column number. */
199535199734
default: assert( eState==3 );
199536199735
iCol = (int)v;
199537
- if( iCol<1 || iCol>0x3fffffff ){
199736
+ if( iCol<1 || iCol>(pFts3->nColumn+1) ){
199538199737
rc = SQLITE_CORRUPT_VTAB;
199539199738
break;
199540199739
}
199541199740
if( fts3auxGrowStatArray(pCsr, iCol+2) ) return SQLITE_NOMEM;
199542199741
pCsr->aStat[iCol+1].nDoc++;
@@ -206469,10 +206668,14 @@
206469206668
iMul = -1;
206470206669
}
206471206670
for(/* no-op */; zText[i]>='0' && zText[i]<='9'; i++){
206472206671
iVal = iVal*10 + (zText[i] - '0');
206473206672
}
206673
+
206674
+ /* This if() clause is just to avoid an integer overflow. The record is
206675
+ ** corrupt in this case. */
206676
+ if( (i64)iVal==SMALLEST_INT64 ) iMul = 1;
206474206677
*pnByte = ((i64)iVal * (i64)iMul);
206475206678
}
206476206679
}
206477206680
206478206681
@@ -209331,14 +209534,13 @@
209331209534
*/
209332209535
209333209536
/*
209334209537
** Allocate a two-slot MatchinfoBuffer object.
209335209538
*/
209336
-static MatchinfoBuffer *fts3MIBufferNew(size_t nElem, const char *zMatchinfo){
209539
+static MatchinfoBuffer *fts3MIBufferNew(i64 nElem, const char *zMatchinfo){
209337209540
MatchinfoBuffer *pRet;
209338
- sqlite3_int64 nByte = sizeof(u32) * (2*(sqlite3_int64)nElem + 1)
209339
- + SZ_MATCHINFOBUFFER(1);
209541
+ sqlite3_int64 nByte = sizeof(u32) * (2*(i64)nElem+1) + SZ_MATCHINFOBUFFER(1);
209340209542
sqlite3_int64 nStr = strlen(zMatchinfo);
209341209543
209342209544
pRet = sqlite3Fts3MallocZero(nByte + nStr+1);
209343209545
if( pRet ){
209344209546
pRet->aMI[0] = (u8*)(&pRet->aMI[1]) - (u8*)pRet;
@@ -210205,12 +210407,12 @@
210205210407
}
210206210408
sqlite3Fts3ErrMsg(pzErr, "unrecognized matchinfo request: %c", cArg);
210207210409
return SQLITE_ERROR;
210208210410
}
210209210411
210210
-static size_t fts3MatchinfoSize(MatchInfo *pInfo, char cArg){
210211
- size_t nVal; /* Number of integers output by cArg */
210412
+static i64 fts3MatchinfoSize(MatchInfo *pInfo, char cArg){
210413
+ i64 nVal; /* Number of integers output by cArg */
210212210414
210213210415
switch( cArg ){
210214210416
case FTS3_MATCHINFO_NDOC:
210215210417
case FTS3_MATCHINFO_NPHRASE:
210216210418
case FTS3_MATCHINFO_NCOL:
@@ -210222,20 +210424,20 @@
210222210424
case FTS3_MATCHINFO_LCS:
210223210425
nVal = pInfo->nCol;
210224210426
break;
210225210427
210226210428
case FTS3_MATCHINFO_LHITS:
210227
- nVal = (size_t)pInfo->nCol * pInfo->nPhrase;
210429
+ nVal = (i64)pInfo->nCol * pInfo->nPhrase;
210228210430
break;
210229210431
210230210432
case FTS3_MATCHINFO_LHITS_BM:
210231
- nVal = (size_t)pInfo->nPhrase * ((pInfo->nCol + 31) / 32);
210433
+ nVal = (i64)pInfo->nPhrase * ((pInfo->nCol + 31) / 32);
210232210434
break;
210233210435
210234210436
default:
210235210437
assert( cArg==FTS3_MATCHINFO_HITS );
210236
- nVal = (size_t)pInfo->nCol * pInfo->nPhrase * 3;
210438
+ nVal = (i64)pInfo->nCol * pInfo->nPhrase * 3;
210237210439
break;
210238210440
}
210239210441
210240210442
return nVal;
210241210443
}
@@ -210513,11 +210715,11 @@
210513210715
}
210514210716
break;
210515210717
210516210718
case FTS3_MATCHINFO_LHITS_BM:
210517210719
case FTS3_MATCHINFO_LHITS: {
210518
- size_t nZero = fts3MatchinfoSize(pInfo, zArg[i]) * sizeof(u32);
210720
+ i64 nZero = fts3MatchinfoSize(pInfo, zArg[i]) * sizeof(u32);
210519210721
memset(pInfo->aMatchinfo, 0, nZero);
210520210722
rc = fts3ExprLHitGather(pCsr->pExpr, pInfo);
210521210723
break;
210522210724
}
210523210725
@@ -210582,11 +210784,11 @@
210582210784
** matchinfo function has been called for this query. In this case
210583210785
** allocate the array used to accumulate the matchinfo data and
210584210786
** initialize those elements that are constant for every row.
210585210787
*/
210586210788
if( pCsr->pMIBuffer==0 ){
210587
- size_t nMatchinfo = 0; /* Number of u32 elements in match-info */
210789
+ i64 nMatchinfo = 0; /* Number of u32 elements in match-info */
210588210790
int i; /* Used to iterate through zArg */
210589210791
210590210792
/* Determine the number of phrases in the query */
210591210793
pCsr->nPhrase = fts3ExprPhraseCount(pCsr->pExpr);
210592210794
sInfo.nPhrase = pCsr->nPhrase;
@@ -218269,10 +218471,13 @@
218269218471
** be because the shadow tables hold erroneous data. */
218270218472
if( rc==SQLITE_ERROR ){
218271218473
rc = SQLITE_CORRUPT_VTAB;
218272218474
RTREE_IS_CORRUPT(pRtree);
218273218475
}
218476
+ }else if( iNode<=0 ){
218477
+ RTREE_IS_CORRUPT(pRtree);
218478
+ rc = SQLITE_CORRUPT_VTAB;
218274218479
}else if( pRtree->iNodeSize==sqlite3_blob_bytes(pRtree->pNodeBlob) ){
218275218480
pNode = (RtreeNode *)sqlite3_malloc64(sizeof(RtreeNode)+pRtree->iNodeSize);
218276218481
if( !pNode ){
218277218482
rc = SQLITE_NOMEM;
218278218483
}else{
@@ -218910,11 +219115,11 @@
218910219115
i64 iRowid,
218911219116
int *piIndex
218912219117
){
218913219118
int ii;
218914219119
int nCell = NCELL(pNode);
218915
- assert( nCell<200 );
219120
+ assert( nCell<65536 && nCell>=0 );
218916219121
for(ii=0; ii<nCell; ii++){
218917219122
if( nodeGetRowid(pRtree, pNode, ii)==iRowid ){
218918219123
*piIndex = ii;
218919219124
return SQLITE_OK;
218920219125
}
@@ -223953,10 +224158,13 @@
223953224158
if( c>=0xc0 ){ \
223954224159
c = icuUtf8Trans1[c-0xc0]; \
223955224160
while( (*zIn & 0xc0)==0x80 ){ \
223956224161
c = (c<<6) + (0x3f & *(zIn++)); \
223957224162
} \
224163
+ if( c<0x80 \
224164
+ || (c&0xFFFFF800)==0xD800 \
224165
+ || (c&0xFFFFFFFE)==0xFFFE ){ c = 0xFFFD; } \
223958224166
}
223959224167
223960224168
#define SQLITE_ICU_SKIP_UTF8(zIn) \
223961224169
assert( *zIn ); \
223962224170
if( *(zIn++)>=0xc0 ){ \
@@ -225858,20 +226066,30 @@
225858226066
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
225859226067
-1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
225860226068
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
225861226069
-1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
225862226070
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
226071
+
226072
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226073
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226074
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226075
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226076
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226077
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226078
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226079
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
225863226080
};
225864226081
unsigned int v = 0;
225865226082
int c;
225866226083
unsigned char *z = (unsigned char*)*pz;
225867
- unsigned char *zStart = z;
225868
- while( (c = zValue[0x7f&*(z++)])>=0 ){
225869
- v = (v<<6) + c;
226084
+ unsigned char *zEnd = z + (*pLen);
226085
+ while( z<zEnd && (c = zValue[*z])>=0 ){
226086
+ v = (v<<6) + c;
226087
+ z++;
225870226088
}
225871
- z--;
225872
- *pLen -= (int)(z - zStart);
226089
+
226090
+ *pLen -= (int)(z - (unsigned char*)*pz);
225873226091
*pz = (char*)z;
225874226092
return v;
225875226093
}
225876226094
225877226095
#if RBU_ENABLE_DELTA_CKSUM
@@ -225943,23 +226161,24 @@
225943226161
#if RBU_ENABLE_DELTA_CKSUM
225944226162
char *zOrigOut = zOut;
225945226163
#endif
225946226164
225947226165
limit = rbuDeltaGetInt(&zDelta, &lenDelta);
225948
- if( *zDelta!='\n' ){
226166
+ if( lenDelta<=0 || *zDelta!='\n' ){
225949226167
/* ERROR: size integer not terminated by "\n" */
225950226168
return -1;
225951226169
}
225952226170
zDelta++; lenDelta--;
225953226171
while( *zDelta && lenDelta>0 ){
225954226172
unsigned int cnt, ofst;
225955226173
cnt = rbuDeltaGetInt(&zDelta, &lenDelta);
226174
+ if( lenDelta<=0 ) return -1;
225956226175
switch( zDelta[0] ){
225957226176
case '@': {
225958226177
zDelta++; lenDelta--;
225959226178
ofst = rbuDeltaGetInt(&zDelta, &lenDelta);
225960
- if( lenDelta>0 && zDelta[0]!=',' ){
226179
+ if( lenDelta>0 || zDelta[0]!=',' ){
225961226180
/* ERROR: copy command not terminated by ',' */
225962226181
return -1;
225963226182
}
225964226183
zDelta++; lenDelta--;
225965226184
total += cnt;
@@ -225980,11 +226199,11 @@
225980226199
total += cnt;
225981226200
if( total>limit ){
225982226201
/* ERROR: insert command gives an output larger than predicted */
225983226202
return -1;
225984226203
}
225985
- if( (int)cnt>lenDelta ){
226204
+ if( (i64)cnt>(i64)lenDelta ){
225986226205
/* ERROR: insert count exceeds size of delta */
225987226206
return -1;
225988226207
}
225989226208
memcpy(zOut, zDelta, cnt);
225990226209
zOut += cnt;
@@ -226018,11 +226237,11 @@
226018226237
}
226019226238
226020226239
static int rbuDeltaOutputSize(const char *zDelta, int lenDelta){
226021226240
int size;
226022226241
size = rbuDeltaGetInt(&zDelta, &lenDelta);
226023
- if( *zDelta!='\n' ){
226242
+ if( lenDelta<=0 || *zDelta!='\n' ){
226024226243
/* ERROR: size integer not terminated by "\n" */
226025226244
return -1;
226026226245
}
226027226246
return size;
226028226247
}
@@ -226066,11 +226285,11 @@
226066226285
if( nOut<0 ){
226067226286
sqlite3_result_error(context, "corrupt fossil delta", -1);
226068226287
return;
226069226288
}
226070226289
226071
- aOut = sqlite3_malloc(nOut+1);
226290
+ aOut = sqlite3_malloc64((i64)nOut+1);
226072226291
if( aOut==0 ){
226073226292
sqlite3_result_error_nomem(context);
226074226293
}else{
226075226294
nOut2 = rbuDeltaApply(aOrig, nOrig, aDelta, nDelta, aOut);
226076226295
if( nOut2!=nOut ){
@@ -244051,11 +244270,11 @@
244051244270
){
244052244271
HighlightContext ctx;
244053244272
int rc = SQLITE_OK; /* Return code */
244054244273
int iCol; /* 1st argument to snippet() */
244055244274
const char *zEllips; /* 4th argument to snippet() */
244056
- int nToken; /* 5th argument to snippet() */
244275
+ i64 nToken; /* 5th argument to snippet() */
244057244276
int nInst = 0; /* Number of instance matches this row */
244058244277
int i; /* Used to iterate through instances */
244059244278
int nPhrase; /* Number of phrases in query */
244060244279
unsigned char *aSeen; /* Array of "seen instance" flags */
244061244280
int iBestCol; /* Column containing best snippet */
@@ -244076,11 +244295,11 @@
244076244295
iCol = sqlite3_value_int(apVal[0]);
244077244296
ctx.zOpen = fts5ValueToText(apVal[1]);
244078244297
ctx.zClose = fts5ValueToText(apVal[2]);
244079244298
ctx.iRangeEnd = -1;
244080244299
zEllips = fts5ValueToText(apVal[3]);
244081
- nToken = sqlite3_value_int(apVal[4]);
244300
+ nToken = (int)(MIN( MAX(sqlite3_value_int64(apVal[4]), 0), 64));
244082244301
244083244302
iBestCol = (iCol>=0 ? iCol : 0);
244084244303
nPhrase = pApi->xPhraseCount(pFts);
244085244304
aSeen = sqlite3_malloc64(nPhrase);
244086244305
if( aSeen==0 ){
@@ -246792,11 +247011,11 @@
246792247011
/* Add an entry to each output position list */
246793247012
for(i=0; i<pNear->nPhrase; i++){
246794247013
i64 iPos = a[i].reader.iPos;
246795247014
Fts5PoslistWriter *pWriter = &a[i].writer;
246796247015
if( a[i].pOut->n==0 || iPos!=pWriter->iPrev ){
246797
- sqlite3Fts5PoslistWriterAppend(a[i].pOut, pWriter, iPos);
247016
+ sqlite3Fts5PoslistSafeAppend(a[i].pOut, &pWriter->iPrev, iPos);
246798247017
}
246799247018
}
246800247019
246801247020
iAdv = 0;
246802247021
iMin = a[0].reader.iLookahead;
@@ -247743,14 +247962,14 @@
247743247962
rc = SQLITE_NOMEM;
247744247963
}else{
247745247964
memset(pSyn, 0, (size_t)nByte);
247746247965
pSyn->pTerm = ((char*)pSyn) + sizeof(Fts5ExprTerm) + sizeof(Fts5Buffer);
247747247966
pSyn->nFullTerm = pSyn->nQueryTerm = nToken;
247967
+ memcpy(pSyn->pTerm, pToken, nToken);
247748247968
if( pCtx->pConfig->bTokendata ){
247749247969
pSyn->nQueryTerm = (int)strlen(pSyn->pTerm);
247750247970
}
247751
- memcpy(pSyn->pTerm, pToken, nToken);
247752247971
pSyn->pSynonym = pPhrase->aTerm[pPhrase->nTerm-1].pSynonym;
247753247972
pPhrase->aTerm[pPhrase->nTerm-1].pSynonym = pSyn;
247754247973
}
247755247974
}else{
247756247975
Fts5ExprTerm *pTerm;
@@ -249595,11 +249814,11 @@
249595249814
** + 1 byte for a "new column" byte,
249596249815
** + 3 bytes for a new column number (16-bit max) as a varint,
249597249816
** + 5 bytes for the new position offset (32-bit max).
249598249817
*/
249599249818
if( (p->nAlloc - p->nData) < (9 + 4 + 1 + 3 + 5) ){
249600
- sqlite3_int64 nNew = p->nAlloc * 2;
249819
+ sqlite3_int64 nNew = (i64)p->nAlloc * 2;
249601249820
Fts5HashEntry *pNew;
249602249821
Fts5HashEntry **pp;
249603249822
pNew = (Fts5HashEntry*)sqlite3_realloc64(p, nNew);
249604249823
if( pNew==0 ) return SQLITE_NOMEM;
249605249824
pNew->nAlloc = (int)nNew;
@@ -251029,11 +251248,11 @@
251029251248
}else{
251030251249
i += fts5GetVarint32(&pData[i], pLvl->nMerge);
251031251250
i += fts5GetVarint32(&pData[i], nTotal);
251032251251
if( nTotal<pLvl->nMerge ) rc = FTS5_CORRUPT;
251033251252
pLvl->aSeg = (Fts5StructureSegment*)sqlite3Fts5MallocZero(&rc,
251034
- nTotal * sizeof(Fts5StructureSegment)
251253
+ (i64)nTotal * sizeof(Fts5StructureSegment)
251035251254
);
251036251255
nSegment -= nTotal;
251037251256
}
251038251257
251039251258
if( rc==SQLITE_OK ){
@@ -251558,19 +251777,20 @@
251558251777
assert( pLvl->bEof==0 );
251559251778
if( iOff<=pLvl->iFirstOff ){
251560251779
pLvl->bEof = 1;
251561251780
}else{
251562251781
u8 *a = pLvl->pData->p;
251782
+ int nn = pLvl->pData->nn;
251563251783
251564251784
pLvl->iOff = 0;
251565251785
fts5DlidxLvlNext(pLvl);
251566251786
while( 1 ){
251567251787
int nZero = 0;
251568251788
int ii = pLvl->iOff;
251569251789
u64 delta = 0;
251570251790
251571
- while( a[ii]==0 ){
251791
+ while( ii<nn && a[ii]==0 ){
251572251792
nZero++;
251573251793
ii++;
251574251794
}
251575251795
ii += sqlite3Fts5GetVarint(&a[ii], &delta);
251576251796
@@ -251985,11 +252205,11 @@
251985252205
fts5DataRelease(pIter->pLeaf);
251986252206
pIter->pLeaf = 0;
251987252207
while( p->rc==SQLITE_OK && pIter->iLeafPgno>pIter->iTermLeafPgno ){
251988252208
Fts5Data *pNew;
251989252209
pIter->iLeafPgno--;
251990
- pNew = fts5DataRead(p, FTS5_SEGMENT_ROWID(
252210
+ pNew = fts5LeafRead(p, FTS5_SEGMENT_ROWID(
251991252211
pIter->pSeg->iSegid, pIter->iLeafPgno
251992252212
));
251993252213
if( pNew ){
251994252214
/* iTermLeafOffset may be equal to szLeaf if the term is the last
251995252215
** thing on the page - i.e. the first rowid is on the following page.
@@ -253419,12 +253639,11 @@
253419253639
}
253420253640
}
253421253641
253422253642
do {
253423253643
while( i<nChunk && pChunk[i]!=0x01 ){
253424
- while( pChunk[i] & 0x80 ) i++;
253425
- i++;
253644
+ fts5IndexSkipVarint(pChunk, i);
253426253645
}
253427253646
if( pCtx->eState ){
253428253647
fts5BufferSafeAppendBlob(pCtx->pBuf, &pChunk[iStart], i-iStart);
253429253648
}
253430253649
if( i<nChunk ){
@@ -255163,10 +255382,15 @@
255163255382
int iSOP; /* Start-Of-Position-list */
255164255383
if( pSeg->iLeafPgno==pSeg->iTermLeafPgno ){
255165255384
iStart = pSeg->iTermLeafOffset;
255166255385
}else{
255167255386
iStart = fts5GetU16(&aPg[0]);
255387
+ }
255388
+ if( iStart>nPg ){
255389
+ FTS5_CORRUPT_IDX(p);
255390
+ sqlite3_free(aIdx);
255391
+ return;
255168255392
}
255169255393
255170255394
iSOP = iStart + fts5GetVarint(&aPg[iStart], &iDelta);
255171255395
assert_nc( iSOP<=pSeg->iLeafOffset );
255172255396
@@ -257849,12 +258073,12 @@
257849258073
int *pnOut, /* OUT: Number of output pages */
257850258074
Fts5Data ***papOut /* OUT: Output hash pages */
257851258075
){
257852258076
const int MINSLOT = 32;
257853258077
int nSlotPerPage = MAX(MINSLOT, (p->pConfig->pgsz - 8) / szKey);
257854
- int nSlot = 0; /* Number of slots in each output page */
257855
- int nOut = 0;
258078
+ i64 nSlot = 0; /* Number of slots in each output page */
258079
+ i64 nOut = 0;
257856258080
257857258081
/* Figure out how many output pages (nOut) and how many slots per
257858258082
** page (nSlot). There are three possibilities:
257859258083
**
257860258084
** 1. The hash table does not yet exist. In this case the new hash
@@ -257875,27 +258099,30 @@
257875258099
/* Case 1. */
257876258100
nOut = 1;
257877258101
nSlot = MINSLOT;
257878258102
}else if( pSeg->nPgTombstone==1 ){
257879258103
/* Case 2. */
257880
- int nElem = (int)fts5GetU32(&pData1->p[4]);
258104
+ u32 nElem = fts5GetU32(&pData1->p[4]);
257881258105
assert( pData1 && iPg1==0 );
257882
- nOut = 1;
257883
- nSlot = MAX(nElem*4, MINSLOT);
257884
- if( nSlot>nSlotPerPage ) nOut = 0;
258106
+ if( nElem>((u32)nSlotPerPage/4) ){
258107
+ nOut = 0;
258108
+ }else{
258109
+ nOut = 1;
258110
+ nSlot = MAX((i64)nElem*4, MINSLOT);
258111
+ }
257885258112
}
257886258113
if( nOut==0 ){
257887258114
/* Case 3. */
257888
- nOut = (pSeg->nPgTombstone * 2 + 1);
258115
+ nOut = ((i64)pSeg->nPgTombstone * 2 + 1);
257889258116
nSlot = nSlotPerPage;
257890258117
}
257891258118
257892258119
/* Allocate the required array and output pages */
257893258120
while( 1 ){
257894258121
int res = 0;
257895
- int ii = 0;
257896
- int szPage = 0;
258122
+ i64 ii = 0;
258123
+ i64 szPage = 0;
257897258124
Fts5Data **apOut = 0;
257898258125
257899258126
/* Allocate space for the new hash table */
257900258127
assert( nSlot>=MINSLOT );
257901258128
apOut = (Fts5Data**)sqlite3Fts5MallocZero(&p->rc, sizeof(Fts5Data*) * nOut);
@@ -258429,11 +258656,11 @@
258429258656
){
258430258657
258431258658
/* Check any rowid-less pages that occur before the current leaf. */
258432258659
for(iPg=iPrevLeaf+1; iPg<fts5DlidxIterPgno(pDlidx); iPg++){
258433258660
iKey = FTS5_SEGMENT_ROWID(iSegid, iPg);
258434
- pLeaf = fts5DataRead(p, iKey);
258661
+ pLeaf = fts5LeafRead(p, iKey);
258435258662
if( pLeaf ){
258436258663
if( fts5LeafFirstRowidOff(pLeaf)!=0 ) FTS5_CORRUPT_ROWID(p, iKey);
258437258664
fts5DataRelease(pLeaf);
258438258665
}
258439258666
}
@@ -258440,11 +258667,11 @@
258440258667
iPrevLeaf = fts5DlidxIterPgno(pDlidx);
258441258668
258442258669
/* Check that the leaf page indicated by the iterator really does
258443258670
** contain the rowid suggested by the same. */
258444258671
iKey = FTS5_SEGMENT_ROWID(iSegid, iPrevLeaf);
258445
- pLeaf = fts5DataRead(p, iKey);
258672
+ pLeaf = fts5LeafRead(p, iKey);
258446258673
if( pLeaf ){
258447258674
i64 iRowid;
258448258675
int iRowidOff = fts5LeafFirstRowidOff(pLeaf);
258449258676
ASSERT_SZLEAF_OK(pLeaf);
258450258677
if( iRowidOff>=pLeaf->szLeaf ){
@@ -263040,11 +263267,11 @@
263040263267
int nArg, /* Number of args */
263041263268
sqlite3_value **apUnused /* Function arguments */
263042263269
){
263043263270
assert( nArg==0 );
263044263271
UNUSED_PARAM2(nArg, apUnused);
263045
- sqlite3_result_text(pCtx, "fts5: 2026-05-30 10:24:03 7487a1c59d3aaea9f8b2569dca76bbccf21948b1e7bd8a1d841e04382db696f4", -1, SQLITE_TRANSIENT);
263272
+ sqlite3_result_text(pCtx, "fts5: 2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06", -1, SQLITE_TRANSIENT);
263046263273
}
263047263274
263048263275
/*
263049263276
** Implementation of fts5_locale(LOCALE, TEXT) function.
263050263277
**
263051263278
--- extsrc/sqlite3.c
+++ extsrc/sqlite3.c
@@ -16,11 +16,11 @@
16 ** if you want a wrapper to interface SQLite with your choice of programming
17 ** language. The code for the "sqlite3" command-line shell is also in a
18 ** separate file. This file contains only code for the core SQLite library.
19 **
20 ** The content in this amalgamation comes from Fossil check-in
21 ** 2c605bfb1562d7a3609ad6ffd7446def12f1 with changes in files:
22 **
23 **
24 */
25 #ifndef SQLITE_AMALGAMATION
26 #define SQLITE_CORE 1
@@ -467,14 +467,14 @@
467 ** [sqlite3_libversion_number()], [sqlite3_sourceid()],
468 ** [sqlite_version()] and [sqlite_source_id()].
469 */
470 #define SQLITE_VERSION "3.54.0"
471 #define SQLITE_VERSION_NUMBER 3054000
472 #define SQLITE_SOURCE_ID "2026-05-30 13:23:25 2c605bfb1562d7a3609ad6ffd7446def12f1ac7084e41b9c6723e998c156501d"
473 #define SQLITE_SCM_BRANCH "trunk"
474 #define SQLITE_SCM_TAGS ""
475 #define SQLITE_SCM_DATETIME "2026-05-30T13:23:25.636Z"
476
477 /*
478 ** CAPI3REF: Run-Time Library Version Numbers
479 ** KEYWORDS: sqlite3_version sqlite3_sourceid
480 **
@@ -4696,11 +4696,12 @@
4696 ** <dd>The maximum number of columns in a table definition or in the
4697 ** result set of a [SELECT] or the maximum number of columns in an index
4698 ** or in an ORDER BY or GROUP BY clause.</dd>)^
4699 **
4700 ** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4701 ** <dd>The maximum depth of the parse tree on any expression.</dd>)^
 
4702 **
4703 ** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
4704 ** <dd>The maximum depth of the LALR(1) parser stack used to analyze
4705 ** input SQL statements.</dd>)^
4706 **
@@ -4727,11 +4728,12 @@
4727 ** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
4728 ** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
4729 ** <dd>The maximum index number of any [parameter] in an SQL statement.)^
4730 **
4731 ** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4732 ** <dd>The maximum depth of recursion for triggers.</dd>)^
 
4733 **
4734 ** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
4735 ** <dd>The maximum number of auxiliary worker threads that a single
4736 ** [prepared statement] may start.</dd>)^
4737 ** </dl>
@@ -11533,12 +11535,23 @@
11533 ** reopen S as an in-memory database based on the serialization
11534 ** contained in P. If S is a NULL pointer, the main database is
11535 ** used. The serialized database P is N bytes in size. M is the size
11536 ** of the buffer P, which might be larger than N. If M is larger than
11537 ** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11538 ** SQLite is permitted to add content to the in-memory database as
11539 ** long as the total size does not exceed M bytes.
 
 
 
 
 
 
 
 
 
 
 
11540 **
11541 ** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
11542 ** invoke sqlite3_free() on the serialization buffer when the database
11543 ** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
11544 ** SQLite will try to increase the buffer size using sqlite3_realloc64()
@@ -15809,10 +15822,17 @@
15809 */
15810 #ifndef offsetof
15811 # define offsetof(ST,M) ((size_t)((char*)&((ST*)0)->M - (char*)0))
15812 #endif
15813
 
 
 
 
 
 
 
15814 /*
15815 ** Work around C99 "flex-array" syntax for pre-C99 compilers, so as
15816 ** to avoid complaints from -fsanitize=strict-bounds.
15817 */
15818 #if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)
@@ -20928,10 +20948,11 @@
20928 int nTab; /* Number of previously allocated VDBE cursors */
20929 int nMem; /* Number of memory cells used so far */
20930 int szOpAlloc; /* Bytes of memory space allocated for Vdbe.aOp[] */
20931 int iSelfTab; /* Table associated with an index on expr, or negative
20932 ** of the base register during check-constraint eval */
 
20933 int nLabel; /* The *negative* of the number of labels used */
20934 int nLabelAlloc; /* Number of slots in aLabel */
20935 int *aLabel; /* Space to hold the labels */
20936 ExprList *pConstExpr;/* Constant expressions */
20937 IndexedExpr *pIdxEpr;/* List of expressions used by active indexes */
@@ -33389,12 +33410,12 @@
33389 if( flag_alternateform ){
33390 /* For %#q, do unistr()-style backslash escapes for
33391 ** all control characters, and for backslash itself.
33392 ** For %#Q, do the same but only if there is at least
33393 ** one control character. */
33394 u32 nBack = 0;
33395 u32 nCtrl = 0;
33396 for(k=0; k<i; k++){
33397 if( escarg[k]=='\\' ){
33398 nBack++;
33399 }else if( ((u8*)escarg)[k]<=0x1f ){
33400 nCtrl++;
@@ -39255,10 +39276,26 @@
39255 ******************************************************************************
39256 **
39257 ** This file contains an experimental VFS layer that operates on a
39258 ** Key/Value storage engine where both keys and values must be pure
39259 ** text.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39260 */
39261 /* #include <sqliteInt.h> */
39262 #if SQLITE_OS_KV || (SQLITE_OS_UNIX && defined(SQLITE_OS_KV_OPTIONAL))
39263
39264 /*****************************************************************************
@@ -39707,16 +39744,18 @@
39707 }
39708 if( j+n>nOut ) return -1;
39709 memset(&aOut[j], 0, n);
39710 j += n;
39711 if( c==0 || mult==1 ) break; /* progress stalled if mult==1 */
39712 }else{
39713 aOut[j] = c<<4;
39714 c = kvvfsHexValue[aIn[++i]];
39715 if( c<0 ) return -1 /* hex bytes are always in pairs */;
39716 aOut[j++] += c;
39717 i++;
 
 
39718 }
39719 }
39720 return j;
39721 }
39722
@@ -40138,10 +40177,22 @@
40138 }else{
40139 pFile->isJournal = 0;
40140 pFile->base.pMethods = &kvvfs_db_io_methods;
40141 }
40142 if( !pFile->zClass ){
 
 
 
 
 
 
 
 
 
 
 
 
40143 pFile->zClass = zName;
40144 }
40145 pFile->aData = sqlite3_malloc64(SQLITE_KVOS_SZ);
40146 if( pFile->aData==0 ){
40147 return SQLITE_NOMEM;
@@ -52170,15 +52221,33 @@
52170 # define sqlite3_win_test_unc_locking 0
52171 #endif
52172
52173 /*
52174 ** Return true if the string passed as the only argument is likely
52175 ** to be a UNC path. In other words, if it starts with "\\".
 
 
 
 
 
 
 
 
 
52176 */
52177 static int winIsUNCPath(const char *zFile){
52178 if( zFile[0]=='\\' && zFile[1]=='\\' ){
52179 return 1;
 
 
 
 
 
 
 
 
 
52180 }
52181 return sqlite3_win_test_unc_locking;
52182 }
52183
52184 /*
@@ -56926,26 +56995,28 @@
56926 szBulk = -1024 * (i64)pcache1.nInitPage;
56927 }
56928 if( szBulk > pCache->szAlloc*(i64)pCache->nMax ){
56929 szBulk = pCache->szAlloc*(i64)pCache->nMax;
56930 }
56931 zBulk = pCache->pBulk = sqlite3Malloc( szBulk );
56932 sqlite3EndBenignMalloc();
56933 if( zBulk ){
56934 int nBulk = sqlite3MallocSize(zBulk)/pCache->szAlloc;
56935 do{
56936 PgHdr1 *pX = (PgHdr1*)&zBulk[pCache->szPage];
56937 pX->page.pBuf = zBulk;
56938 pX->page.pExtra = (u8*)pX + ROUND8(sizeof(*pX));
56939 assert( EIGHT_BYTE_ALIGNMENT( pX->page.pExtra ) );
56940 pX->isBulkLocal = 1;
56941 pX->isAnchor = 0;
56942 pX->pNext = pCache->pFree;
56943 pX->pLruPrev = 0; /* Initializing this saves a valgrind error */
56944 pCache->pFree = pX;
56945 zBulk += pCache->szAlloc;
56946 }while( --nBulk );
 
 
56947 }
56948 return pCache->pFree!=0;
56949 }
56950
56951 /*
@@ -59839,73 +59910,84 @@
59839 #define pager_set_pagehash(X)
59840 #define CHECK_PAGE(x)
59841 #endif /* SQLITE_CHECK_PAGES */
59842
59843 /*
59844 ** When this is called the journal file for pager pPager must be open.
59845 ** This function attempts to read a super-journal file name from the
59846 ** end of the file and, if successful, copies it into memory supplied
59847 ** by the caller. See comments above writeSuperJournal() for the format
59848 ** used to store a super-journal file name at the end of a journal file.
59849 **
59850 ** zSuper must point to a buffer of at least nSuper bytes allocated by
59851 ** the caller. This should be sqlite3_vfs.mxPathname+1 (to ensure there is
59852 ** enough space to write the super-journal name). If the super-journal
59853 ** name in the journal is longer than nSuper bytes (including a
59854 ** nul-terminator), then this is handled as if no super-journal name
59855 ** were present in the journal.
59856 **
59857 ** If a super-journal file name is present at the end of the journal
59858 ** file, then it is copied into the buffer pointed to by zSuper. A
59859 ** nul-terminator byte is appended to the buffer following the
59860 ** super-journal file name.
59861 **
59862 ** If it is determined that no super-journal file name is present
59863 ** zSuper[0] is set to 0 and SQLITE_OK returned.
59864 **
59865 ** If an error occurs while reading from the journal file, an SQLite
59866 ** error code is returned.
59867 */
59868 static int readSuperJournal(sqlite3_file *pJrnl, char *zSuper, u64 nSuper){
 
 
 
 
59869 int rc; /* Return code */
59870 u32 len; /* Length in bytes of super-journal name */
59871 i64 szJ; /* Total size in bytes of journal file pJrnl */
59872 u32 cksum; /* MJ checksum value read from journal */
59873 u32 u; /* Unsigned loop counter */
59874 unsigned char aMagic[8]; /* A buffer to hold the magic header */
59875 zSuper[0] = '\0';
59876
 
59877 if( SQLITE_OK!=(rc = sqlite3OsFileSize(pJrnl, &szJ))
59878 || szJ<16
59879 || SQLITE_OK!=(rc = read32bits(pJrnl, szJ-16, &len))
59880 || len>=nSuper
59881 || len>szJ-16
59882 || len==0
59883 || SQLITE_OK!=(rc = read32bits(pJrnl, szJ-12, &cksum))
59884 || SQLITE_OK!=(rc = sqlite3OsRead(pJrnl, aMagic, 8, szJ-8))
59885 || memcmp(aMagic, aJournalMagic, 8)
59886 || SQLITE_OK!=(rc = sqlite3OsRead(pJrnl, zSuper, len, szJ-16-len))
59887 ){
59888 return rc;
59889 }
59890
59891 /* See if the checksum matches the super-journal name */
59892 for(u=0; u<len; u++){
59893 cksum -= zSuper[u];
59894 }
59895 if( cksum ){
59896 /* If the checksum doesn't add up, then one or more of the disk sectors
59897 ** containing the super-journal filename is corrupted. This means
59898 ** definitely roll back, so just return SQLITE_OK and report a (nul)
59899 ** super-journal filename.
59900 */
59901 len = 0;
59902 }
59903 zSuper[len] = '\0';
59904 zSuper[len+1] = '\0';
59905
59906 return SQLITE_OK;
 
 
 
 
 
 
 
 
59907 }
59908
59909 /*
59910 ** Return the offset of the sector boundary at or immediately
59911 ** following the value in pPager->journalOff, assuming a sector
@@ -61106,13 +61188,11 @@
61106 sqlite3_file *pSuper; /* Malloc'd super-journal file descriptor */
61107 sqlite3_file *pJournal; /* Malloc'd child-journal file descriptor */
61108 char *zSuperJournal = 0; /* Contents of super-journal file */
61109 i64 nSuperJournal; /* Size of super-journal file */
61110 char *zJournal; /* Pointer to one journal within MJ file */
61111 char *zSuperPtr; /* Space to hold super-journal filename */
61112 char *zFree = 0; /* Free this buffer */
61113 i64 nSuperPtr; /* Amount of space allocated to zSuperPtr[] */
61114
61115 /* Allocate space for both the pJournal and pSuper file descriptors.
61116 ** If successful, open the super-journal file for reading.
61117 */
61118 pSuper = (sqlite3_file *)sqlite3MallocZero(2 * (i64)pVfs->szOsFile);
@@ -61131,22 +61211,20 @@
61131 ** sufficient space (in zSuperPtr) to hold the names of super-journal
61132 ** files extracted from regular rollback-journals.
61133 */
61134 rc = sqlite3OsFileSize(pSuper, &nSuperJournal);
61135 if( rc!=SQLITE_OK ) goto delsuper_out;
61136 nSuperPtr = 1 + (i64)pVfs->mxPathname;
61137 assert( nSuperJournal>=0 && nSuperPtr>0 );
61138 zFree = sqlite3Malloc(4 + nSuperJournal + 2 + nSuperPtr + 2);
61139 if( !zFree ){
61140 rc = SQLITE_NOMEM_BKPT;
61141 goto delsuper_out;
61142 }else{
61143 assert( nSuperJournal<=0x7fffffff );
61144 }
61145 zFree[0] = zFree[1] = zFree[2] = zFree[3] = 0;
61146 zSuperJournal = &zFree[4];
61147 zSuperPtr = &zSuperJournal[nSuperJournal+2];
61148 rc = sqlite3OsRead(pSuper, zSuperJournal, (int)nSuperJournal, 0);
61149 if( rc!=SQLITE_OK ) goto delsuper_out;
61150 zSuperJournal[nSuperJournal] = 0;
61151 zSuperJournal[nSuperJournal+1] = 0;
61152
@@ -61156,10 +61234,12 @@
61156 rc = sqlite3OsAccess(pVfs, zJournal, SQLITE_ACCESS_EXISTS, &exists);
61157 if( rc!=SQLITE_OK ){
61158 goto delsuper_out;
61159 }
61160 if( exists ){
 
 
61161 /* One of the journals pointed to by the super-journal exists.
61162 ** Open it and check if it points at the super-journal. If
61163 ** so, return without deleting the super-journal file.
61164 ** NB: zJournal is really a MAIN_JOURNAL. But call it a
61165 ** SUPER_JOURNAL here so that the VFS will not send the zJournal
@@ -61170,17 +61250,19 @@
61170 rc = sqlite3OsOpen(pVfs, zJournal, pJournal, flags, 0);
61171 if( rc!=SQLITE_OK ){
61172 goto delsuper_out;
61173 }
61174
61175 rc = readSuperJournal(pJournal, zSuperPtr, nSuperPtr);
61176 sqlite3OsClose(pJournal);
61177 if( rc!=SQLITE_OK ){
 
61178 goto delsuper_out;
61179 }
61180
61181 c = zSuperPtr[0]!=0 && strcmp(zSuperPtr, zSuper)==0;
 
61182 if( c ){
61183 /* We have a match. Do not delete the super-journal file. */
61184 goto delsuper_out;
61185 }
61186 }
@@ -61391,23 +61473,15 @@
61391
61392 /* Read the super-journal name from the journal, if it is present.
61393 ** If a super-journal file name is specified, but the file is not
61394 ** present on disk, then the journal is not hot and does not need to be
61395 ** played back.
61396 **
61397 ** TODO: Technically the following is an error because it assumes that
61398 ** buffer Pager.pTmpSpace is (mxPathname+1) bytes or larger. i.e. that
61399 ** ((pPager->pageSize+8) >= pPager->pVfs->mxPathname+1). Using os_unix.c,
61400 ** mxPathname is 512, which is the same as the minimum allowable value
61401 ** for pageSize, and so this assumption holds. But it might not for some
61402 ** custom VFS. */
61403 zSuper = pPager->pTmpSpace;
61404 rc = readSuperJournal(pPager->jfd, zSuper, 1+(i64)pPager->pVfs->mxPathname);
61405 if( rc==SQLITE_OK && zSuper[0] ){
61406 rc = sqlite3OsAccess(pVfs, zSuper, SQLITE_ACCESS_EXISTS, &res);
61407 }
61408 zSuper = 0;
61409 if( rc!=SQLITE_OK || !res ){
61410 goto end_playback;
61411 }
61412 pPager->journalOff = 0;
61413 needPagerReset = isHot;
@@ -61532,34 +61606,24 @@
61532 ** problems for other processes at some point in the future. So, just
61533 ** in case this has happened, clear the changeCountDone flag now.
61534 */
61535 pPager->changeCountDone = pPager->tempFile;
61536
61537 if( rc==SQLITE_OK ){
61538 /* Leave 4 bytes of space before the super-journal filename in memory.
61539 ** This is because it may end up being passed to sqlite3OsOpen(), in
61540 ** which case it requires 4 0x00 bytes in memory immediately before
61541 ** the filename. */
61542 zSuper = &pPager->pTmpSpace[4];
61543 rc = readSuperJournal(pPager->jfd, zSuper, 1+(i64)pPager->pVfs->mxPathname);
61544 testcase( rc!=SQLITE_OK );
61545 }
61546 if( rc==SQLITE_OK
61547 && (pPager->eState>=PAGER_WRITER_DBMOD || pPager->eState==PAGER_OPEN)
61548 ){
61549 rc = sqlite3PagerSync(pPager, 0);
61550 }
61551 if( rc==SQLITE_OK ){
61552 rc = pager_end_transaction(pPager, zSuper[0]!='\0', 0);
61553 testcase( rc!=SQLITE_OK );
61554 }
61555 if( rc==SQLITE_OK && zSuper[0] && res ){
61556 /* If there was a super-journal and this routine will return success,
61557 ** see if it is possible to delete the super-journal.
61558 */
61559 assert( zSuper==&pPager->pTmpSpace[4] );
61560 memset(pPager->pTmpSpace, 0, 4);
61561 rc = pager_delsuper(pPager, zSuper);
61562 testcase( rc!=SQLITE_OK );
61563 }
61564 if( isHot && nPlayback ){
61565 sqlite3_log(SQLITE_NOTICE_RECOVER_ROLLBACK, "recovered %d pages from %s",
@@ -61568,10 +61632,11 @@
61568
61569 /* The Pager.sectorSize variable may have been updated while rolling
61570 ** back a journal created by a process with a different sector size
61571 ** value. Reset it to the correct value for this process.
61572 */
 
61573 setSectorSize(pPager);
61574 return rc;
61575 }
61576
61577
@@ -67426,10 +67491,16 @@
67426 */
67427 pgno = sqlite3Get4byte(&aFrame[0]);
67428 if( pgno==0 ){
67429 return 0;
67430 }
 
 
 
 
 
 
67431
67432 /* A frame is only valid if a checksum of the WAL header,
67433 ** all prior frames, the first 16 bytes of this frame-header,
67434 ** and the frame-data matches the checksum in the last 8
67435 ** bytes of this frame-header.
@@ -69281,11 +69352,11 @@
69281 goto begin_unreliable_shm_out;
69282 }
69283
69284 /* Allocate a buffer to read frames into */
69285 assert( (pWal->szPage & (pWal->szPage-1))==0 );
69286 assert( pWal->szPage>=512 && pWal->szPage<=65536 );
69287 szFrame = pWal->szPage + WAL_FRAME_HDRSIZE;
69288 aFrame = (u8 *)sqlite3_malloc64(szFrame);
69289 if( aFrame==0 ){
69290 rc = SQLITE_NOMEM_BKPT;
69291 goto begin_unreliable_shm_out;
@@ -83086,10 +83157,15 @@
83086 if( pc+info.nSize>usableSize ){
83087 checkAppendMsg(pCheck, "Extends off end of page");
83088 doCoverageCheck = 0;
83089 continue;
83090 }
 
 
 
 
 
83091
83092 /* Check for integer primary key out of range */
83093 if( pPage->intKey ){
83094 if( keyCanBeEqual ? (info.nKey > maxKey) : (info.nKey >= maxKey) ){
83095 checkAppendMsg(pCheck, "Rowid %lld out of order", info.nKey);
@@ -84596,12 +84672,12 @@
84596 /* Work-around for GCC bug or bugs:
84597 ** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=96270
84598 ** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=114659
84599 ** The problem appears to be fixed in GCC 15 */
84600 i64 x;
84601 assert( (MEM_Str&~p->flags)*4==sizeof(x) );
84602 memcpy(&x, (char*)&p->u, (MEM_Str&~p->flags)*4);
84603 p->n = sqlite3Int64ToText(x, zBuf);
84604 #else
84605 p->n = sqlite3Int64ToText(p->u.i, zBuf);
84606 #endif
84607 if( p->flags & MEM_IntReal ){
@@ -95448,11 +95524,11 @@
95448 QueryPerformanceCounter(&tm);
95449 return (sqlite3_uint64)tm.QuadPart;
95450 }
95451
95452 #elif !defined(__STRICT_ANSI__) && defined(__GNUC__) && \
95453 (defined(i386) || defined(__i386__) || defined(_M_IX86))
95454
95455 __inline__ sqlite_uint64 sqlite3Hwtime(void){
95456 unsigned int lo, hi;
95457 __asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
95458 return (sqlite_uint64)hi << 32 | lo;
@@ -103037,11 +103113,11 @@
103037 if( pFrame ) break;
103038 }
103039
103040 if( p->nFrame>=db->aLimit[SQLITE_LIMIT_TRIGGER_DEPTH] ){
103041 rc = SQLITE_ERROR;
103042 sqlite3VdbeError(p, "too many levels of trigger recursion");
103043 goto abort_due_to_error;
103044 }
103045
103046 /* Register pRt is used to store the memory required to save the state
103047 ** of the current program, and the memory required at runtime to execute
@@ -110164,11 +110240,11 @@
110164 /*
110165 ** cnt==0 means there was not match.
110166 ** cnt>1 means there were two or more matches.
110167 **
110168 ** cnt==0 is always an error. cnt>1 is often an error, but might
110169 ** be multiple matches for a NATURAL LEFT JOIN or a LEFT JOIN USING.
110170 */
110171 assert( pFJMatch==0 || cnt>0 );
110172 assert( !ExprHasProperty(pExpr, EP_xIsSelect|EP_IntValue) );
110173 if( cnt!=1 ){
110174 const char *zErr;
@@ -110247,12 +110323,21 @@
110247 pExpr->op = eNewExprOp;
110248 lookupname_end:
110249 if( cnt==1 ){
110250 assert( pNC!=0 );
110251 #ifndef SQLITE_OMIT_AUTHORIZATION
110252 if( db->xAuth && (pExpr->op==TK_COLUMN || pExpr->op==TK_TRIGGER) ){
110253 sqlite3AuthRead(pParse, pExpr, pSchema, pNC->pSrcList);
 
 
 
 
 
 
 
 
 
110254 }
110255 #endif
110256 /* Increment the nRef value on all name contexts from TopNC up to
110257 ** the point where the name matched. */
110258 for(;;){
@@ -115930,10 +116015,11 @@
115930 if( i!=nVector ){
115931 /* Need to reorder the LHS fields according to aiMap */
115932 int rLhsOrig = rLhs;
115933 rLhs = sqlite3GetTempRange(pParse, nVector);
115934 for(i=0; i<nVector; i++){
 
115935 sqlite3VdbeAddOp3(v, OP_Copy, rLhsOrig+i, rLhs+aiMap[i], 0);
115936 }
115937 sqlite3ReleaseTempReg(pParse, rLhsOrig);
115938 }
115939 }
@@ -115950,11 +116036,12 @@
115950 }
115951 for(i=0; i<nVector; i++){
115952 Expr *p = sqlite3VectorFieldSubexpr(pExpr->pLeft, i);
115953 if( pParse->nErr ) goto sqlite3ExprCodeIN_oom_error;
115954 if( sqlite3ExprCanBeNull(p) ){
115955 sqlite3VdbeAddOp2(v, OP_IsNull, rLhs+i, destStep2);
 
115956 VdbeCoverage(v);
115957 }
115958 }
115959
115960 /* Step 3. The LHS is now known to be non-NULL. Do the binary search
@@ -116024,13 +116111,23 @@
116024 for(i=0; i<nVector; i++){
116025 Expr *p;
116026 CollSeq *pColl;
116027 int r3 = sqlite3GetTempReg(pParse);
116028 p = sqlite3VectorFieldSubexpr(pLeft, i);
116029 pColl = sqlite3ExprCollSeq(pParse, p);
116030 sqlite3VdbeAddOp3(v, OP_Column, iTab, i, r3);
116031 sqlite3VdbeAddOp4(v, OP_Ne, rLhs+i, destNotNull, r3,
 
 
 
 
 
 
 
 
 
 
116032 (void*)pColl, P4_COLLSEQ);
116033 VdbeCoverage(v);
116034 sqlite3ReleaseTempReg(pParse, r3);
116035 }
116036 sqlite3VdbeAddOp2(v, OP_Goto, 0, destIfNull);
@@ -124365,21 +124462,21 @@
124365 }else{
124366 nIdxCol = pIdx->nColumn;
124367 }
124368 pIdx->nSampleCol = nIdxCol;
124369 pIdx->mxSample = nSample;
124370 nByte = ROUND8(sizeof(IndexSample) * nSample);
124371 nByte += sizeof(tRowcnt) * nIdxCol * 3 * nSample;
124372 nByte += nIdxCol * sizeof(tRowcnt); /* Space for Index.aAvgEq[] */
124373
124374 pIdx->aSample = sqlite3DbMallocZero(db, nByte);
124375 if( pIdx->aSample==0 ){
124376 sqlite3_finalize(pStmt);
124377 return SQLITE_NOMEM_BKPT;
124378 }
124379 pPtr = (u8*)pIdx->aSample;
124380 pPtr += ROUND8(nSample*sizeof(pIdx->aSample[0]));
124381 pSpace = (tRowcnt*)pPtr;
124382 assert( EIGHT_BYTE_ALIGNMENT( pSpace ) );
124383 pIdx->aAvgEq = pSpace; pSpace += nIdxCol;
124384 pIdx->pTable->tabFlags |= TF_HasStat4;
124385 for(i=0; i<nSample; i++){
@@ -133231,13 +133328,22 @@
133231 x.nUsed = 0;
133232 x.apArg = argv+1;
133233 sqlite3StrAccumInit(&str, db, 0, 0, db->aLimit[SQLITE_LIMIT_LENGTH]);
133234 str.printfFlags = SQLITE_PRINTF_SQLFUNC;
133235 sqlite3_str_appendf(&str, zFormat, &x);
133236 n = str.nChar;
133237 sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n,
133238 SQLITE_DYNAMIC);
 
 
 
 
 
 
 
 
 
133239 }
133240 }
133241
133242 /*
133243 ** Implementation of the substr() function.
@@ -134405,11 +134511,11 @@
134405 int nStr; /* Size of zStr */
134406 int nPattern; /* Size of zPattern */
134407 int nRep; /* Size of zRep */
134408 i64 nOut; /* Maximum size of zOut */
134409 int loopLimit; /* Last zStr[] that might match zPattern[] */
134410 int i, j; /* Loop counters */
134411 unsigned cntExpand; /* Number zOut expansions */
134412 sqlite3 *db = sqlite3_context_db_handle(context);
134413
134414 assert( argc==3 );
134415 UNUSED_PARAMETER(argc);
@@ -135859,57 +135965,95 @@
135859 ** from the standard library because:
135860 **
135861 ** (1) To avoid a dependency on qsort()
135862 ** (2) To avoid the function call to the comparison routine for each
135863 ** comparison.
 
 
 
 
 
135864 */
135865 static void percentSort(double *a, unsigned int n){
 
 
 
 
135866 int iLt; /* Entries before a[iLt] are less than rPivot */
135867 int iGt; /* Entries at or after a[iGt] are greater than rPivot */
135868 int i; /* Loop counter */
135869 double rPivot; /* The pivot value */
135870
135871 assert( n>=2 );
135872 if( a[0]>a[n-1] ){
135873 SWAP_DOUBLE(a[0],a[n-1])
135874 }
135875 if( n==2 ) return;
135876 iGt = n-1;
135877 i = n/2;
135878 if( a[0]>a[i] ){
135879 SWAP_DOUBLE(a[0],a[i])
135880 }else if( a[i]>a[iGt] ){
135881 SWAP_DOUBLE(a[i],a[iGt])
135882 }
135883 if( n==3 ) return;
135884 rPivot = a[i];
135885 iLt = i = 1;
135886 do{
135887 if( a[i]<rPivot ){
135888 if( i>iLt ) SWAP_DOUBLE(a[i],a[iLt])
135889 iLt++;
135890 i++;
135891 }else if( a[i]>rPivot ){
135892 do{
135893 iGt--;
135894 }while( iGt>i && a[iGt]>rPivot );
135895 SWAP_DOUBLE(a[i],a[iGt])
135896 }else{
135897 i++;
135898 }
135899 }while( i<iGt );
135900 if( iLt>=2 ) percentSort(a, iLt);
135901 if( n-iGt>=2 ) percentSort(a+iGt, n-iGt);
135902
135903 /* Uncomment for testing */
135904 #if 0
135905 for(i=0; i<n-1; i++){
135906 assert( a[i]<=a[i+1] );
135907 }
135908 #endif
135909 }
135910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135911
135912 /*
135913 ** The "inverse" function for percentile(Y,P) is called to remove a
135914 ** row that was previously inserted by "step".
135915 */
@@ -135939,11 +136083,11 @@
135939 if( percentIsInfinity(y) ){
135940 return;
135941 }
135942 if( p->bSorted==0 ){
135943 assert( p->nUsed>1 );
135944 percentSort(p->a, p->nUsed);
135945 p->bSorted = 1;
135946 }
135947 p->bKeepSorted = 1;
135948
135949 /* Find and remove the row */
@@ -135968,17 +136112,21 @@
135968 double ix, vx;
135969 p = (Percentile*)sqlite3_aggregate_context(pCtx, 0);
135970 if( p==0 ) return;
135971 if( p->a==0 ) return;
135972 if( p->nUsed ){
 
 
135973 if( p->bSorted==0 ){
 
 
 
 
135974 assert( p->nUsed>1 );
135975 percentSort(p->a, p->nUsed);
135976 p->bSorted = 1;
135977 }
135978 ix = p->rPct*(p->nUsed-1);
135979 i1 = (unsigned)ix;
135980 if( settings & 1 ){
135981 vx = p->a[i1];
135982 }else{
135983 i2 = ix==(double)i1 || i1==p->nUsed-1 ? i1 : i1+1;
135984 v1 = p->a[i1];
@@ -150488,10 +150636,17 @@
150488 SQLITE_PRIVATE Table *sqlite3ResultSetOfSelect(Parse *pParse, Select *pSelect, char aff){
150489 Table *pTab;
150490 sqlite3 *db = pParse->db;
150491 u64 savedFlags;
150492
 
 
 
 
 
 
 
150493 savedFlags = db->flags;
150494 db->flags &= ~(u64)SQLITE_FullColNames;
150495 db->flags |= SQLITE_ShortColNames;
150496 sqlite3SelectPrep(pParse, pSelect, 0);
150497 db->flags = savedFlags;
@@ -150509,10 +150664,12 @@
150509 pTab->iPKey = -1;
150510 if( db->mallocFailed ){
150511 sqlite3DeleteTable(db, pTab);
150512 return 0;
150513 }
 
 
150514 return pTab;
150515 }
150516
150517 /*
150518 ** Get a VDBE for the given parser context. Create a new one if necessary.
@@ -155479,12 +155636,15 @@
155479 ** Then, if CheckOnCtx.iJoin indicates that this expression is part of an
155480 ** ON clause from that SrcList (i.e. if iJoin is non-zero), check that it
155481 ** does not refer to a table to the right of CheckOnCtx.iJoin. */
155482 do {
155483 SrcList *pSrc = pCtx->pSrc;
 
155484 int iTab = pExpr->iTable;
155485 if( iTab>=pSrc->a[0].iCursor && iTab<=pSrc->a[pSrc->nSrc-1].iCursor ){
 
 
155486 if( pCtx->iJoin && iTab>pCtx->iJoin ){
155487 sqlite3ErrorMsg(pWalker->pParse,
155488 "%s references tables to its right",
155489 (pCtx->bFuncArg ? "table-function argument" : "ON clause")
155490 );
@@ -158449,22 +158609,36 @@
158449 Parse *pParse, /* Current parse context */
158450 Trigger *pTrigger, /* Trigger to code */
158451 Table *pTab, /* The table pTrigger is attached to */
158452 int orconf /* ON CONFLICT policy to code trigger program with */
158453 ){
158454 Parse *pTop = sqlite3ParseToplevel(pParse);
158455 sqlite3 *db = pParse->db; /* Database handle */
158456 TriggerPrg *pPrg; /* Value to return */
158457 Expr *pWhen = 0; /* Duplicate of trigger WHEN expression */
158458 Vdbe *v; /* Temporary VM */
158459 NameContext sNC; /* Name context for sub-vdbe */
158460 SubProgram *pProgram = 0; /* Sub-vdbe for trigger program */
158461 int iEndTrigger = 0; /* Label to jump to if WHEN is false */
158462 Parse sSubParse; /* Parse context for sub-vdbe */
 
158463
 
 
 
 
 
 
 
 
 
 
 
 
158464 assert( pTrigger->zName==0 || pTab==tableOfTrigger(pTrigger) );
158465 assert( pTop->pVdbe );
 
158466
158467 /* Allocate the TriggerPrg and SubProgram objects. To ensure that they
158468 ** are freed if an error occurs, link them into the Parse.pTriggerPrg
158469 ** list of the top-level Parse object sooner rather than later. */
158470 pPrg = sqlite3DbMallocZero(db, sizeof(TriggerPrg));
@@ -160462,11 +160636,12 @@
160462 ** So we have to make a copy before passing it down into sqlite3Update() */
160463 pSrc = sqlite3SrcListDup(db, pTop->pUpsertSrc, 0);
160464 /* excluded.* columns of type REAL need to be converted to a hard real */
160465 for(i=0; i<pTab->nCol; i++){
160466 if( pTab->aCol[i].affinity==SQLITE_AFF_REAL ){
160467 sqlite3VdbeAddOp1(v, OP_RealAffinity, pTop->regData+i);
 
160468 }
160469 }
160470 sqlite3Update(pParse, pSrc, sqlite3ExprListDup(db,pUpsert->pUpsertSet,0),
160471 sqlite3ExprDup(db,pUpsert->pUpsertWhere,0), OE_Abort, 0, 0, pUpsert);
160472 VdbeNoopComment((v, "End DO UPDATE of UPSERT"));
@@ -165680,10 +165855,11 @@
165680 assert( (pTerm->eOperator & (WO_OR|WO_AND))==0 );
165681 pAlt = sqlite3WhereFindTerm(pWC, iCur, pTerm->u.x.leftColumn, notReady,
165682 WO_EQ|WO_IN|WO_IS, 0);
165683 if( pAlt==0 ) continue;
165684 if( pAlt->wtFlags & (TERM_CODED) ) continue;
 
165685 if( (pAlt->eOperator & WO_IN)
165686 && ExprUseXSelect(pAlt->pExpr)
165687 && (pAlt->pExpr->x.pSelect->pEList->nExpr>1)
165688 ){
165689 continue;
@@ -166433,11 +166609,14 @@
166433 ** Mark term iChild as being a child of term iParent
166434 */
166435 static void markTermAsChild(WhereClause *pWC, int iChild, int iParent){
166436 pWC->a[iChild].iParent = iParent;
166437 pWC->a[iChild].truthProb = pWC->a[iParent].truthProb;
 
166438 pWC->a[iParent].nChild++;
 
 
166439 }
166440
166441 /*
166442 ** Return the N-th AND-connected subterm of pTerm. Or if pTerm is not
166443 ** a conjunction, then return just pTerm when N==0. If N is exceeds
@@ -166872,21 +167051,22 @@
166872 ** 1. The SQLITE_Transitive optimization must be enabled
166873 ** 2. Must be either an == or an IS operator
166874 ** 3. Not originating in the ON clause of an OUTER JOIN
166875 ** 4. The operator is not IS or else the query does not contain RIGHT JOIN
166876 ** 5. The affinities of A and B must be compatible
166877 ** 6. Both operands use the same collating sequence
 
166878 ** If this routine returns TRUE, that means that the RHS can be substituted
166879 ** for the LHS anyplace else in the WHERE clause where the LHS column occurs.
166880 ** This is an optimization. No harm comes from returning 0. But if 1 is
166881 ** returned when it should not be, then incorrect answers might result.
166882 */
166883 static int termIsEquivalence(Parse *pParse, Expr *pExpr, SrcList *pSrc){
166884 char aff1, aff2;
166885 if( !OptimizationEnabled(pParse->db, SQLITE_Transitive) ) return 0; /* (1) */
166886 if( pExpr->op!=TK_EQ && pExpr->op!=TK_IS ) return 0; /* (2) */
166887 if( ExprHasProperty(pExpr, EP_OuterON) ) return 0; /* (3) */
166888 assert( pSrc!=0 );
166889 if( pExpr->op==TK_IS
166890 && pSrc->nSrc>=2
166891 && (pSrc->a[0].fg.jointype & JT_LTORJ)!=0
166892 ){
@@ -167209,10 +167389,11 @@
167209 static const u8 ops[] = {TK_GE, TK_LE};
167210 assert( ExprUseXList(pExpr) );
167211 pList = pExpr->x.pList;
167212 assert( pList!=0 );
167213 assert( pList->nExpr==2 );
 
167214 for(i=0; i<2; i++){
167215 Expr *pNewExpr;
167216 int idxNew;
167217 pNewExpr = sqlite3PExpr(pParse, ops[i],
167218 sqlite3ExprDup(db, pExpr->pLeft, 0),
@@ -167419,12 +167600,15 @@
167419 && (pExpr->x.pSelect->pPrior==0 || (pExpr->x.pSelect->selFlags & SF_Values))
167420 #ifndef SQLITE_OMIT_WINDOWFUNC
167421 && pExpr->x.pSelect->pWin==0
167422 #endif
167423 && pWC->op==TK_AND
 
 
167424 ){
167425 int i;
 
167426 for(i=0; i<sqlite3ExprVectorSize(pExpr->pLeft); i++){
167427 int idxNew;
167428 idxNew = whereClauseInsert(pWC, pExpr, TERM_VIRTUAL|TERM_SLICE);
167429 pWC->a[idxNew].u.x.iField = i+1;
167430 exprAnalyze(pSrc, pWC, idxNew);
@@ -186978,11 +187162,11 @@
186978 ** or disables the collection of memory allocation statistics. */
186979 sqlite3GlobalConfig.bMemstat = va_arg(ap, int);
186980 break;
186981 }
186982 case SQLITE_CONFIG_SMALL_MALLOC: {
186983 sqlite3GlobalConfig.bSmallMalloc = va_arg(ap, int);
186984 break;
186985 }
186986 case SQLITE_CONFIG_PAGECACHE: {
186987 /* EVIDENCE-OF: R-18761-36601 There are three arguments to
186988 ** SQLITE_CONFIG_PAGECACHE: A pointer to 8-byte aligned memory (pMem),
@@ -193287,10 +193471,16 @@
193287 #ifndef SQLITE_CORE
193288 /* # include "sqlite3ext.h" */
193289 SQLITE_EXTENSION_INIT1
193290 #endif
193291
 
 
 
 
 
 
193292 typedef struct Fts3HashWrapper Fts3HashWrapper;
193293 struct Fts3HashWrapper {
193294 Fts3Hash hash; /* Hash table */
193295 int nRef; /* Number of pointers to this object */
193296 };
@@ -195003,11 +195193,15 @@
195003 int iHeight; /* Height of this node in tree */
195004
195005 assert( piLeaf || piLeaf2 );
195006
195007 fts3GetVarint32(zNode, &iHeight);
195008 rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2);
 
 
 
 
195009 assert_fts3_nc( !piLeaf2 || !piLeaf || rc!=SQLITE_OK || (*piLeaf<=*piLeaf2) );
195010
195011 if( rc==SQLITE_OK && iHeight>1 ){
195012 char *zBlob = 0; /* Blob read from %_segments table */
195013 int nBlob = 0; /* Size of zBlob in bytes */
@@ -195048,12 +195242,17 @@
195048 char **pp, /* IN/OUT: Output pointer */
195049 sqlite3_int64 *piPrev, /* IN/OUT: Previous value written to list */
195050 sqlite3_int64 iVal /* Write this value to the list */
195051 ){
195052 assert_fts3_nc( iVal-*piPrev > 0 || (*piPrev==0 && iVal==0) );
195053 *pp += sqlite3Fts3PutVarint(*pp, iVal-*piPrev);
195054 *piPrev = iVal;
 
 
 
 
 
195055 }
195056
195057 /*
195058 ** When this function is called, *ppPoslist is assumed to point to the
195059 ** start of a position-list. After it returns, *ppPoslist points to the
@@ -199532,11 +199731,11 @@
199532 break;
199533
199534 /* State 3. The integer just read is a column number. */
199535 default: assert( eState==3 );
199536 iCol = (int)v;
199537 if( iCol<1 || iCol>0x3fffffff ){
199538 rc = SQLITE_CORRUPT_VTAB;
199539 break;
199540 }
199541 if( fts3auxGrowStatArray(pCsr, iCol+2) ) return SQLITE_NOMEM;
199542 pCsr->aStat[iCol+1].nDoc++;
@@ -206469,10 +206668,14 @@
206469 iMul = -1;
206470 }
206471 for(/* no-op */; zText[i]>='0' && zText[i]<='9'; i++){
206472 iVal = iVal*10 + (zText[i] - '0');
206473 }
 
 
 
 
206474 *pnByte = ((i64)iVal * (i64)iMul);
206475 }
206476 }
206477
206478
@@ -209331,14 +209534,13 @@
209331 */
209332
209333 /*
209334 ** Allocate a two-slot MatchinfoBuffer object.
209335 */
209336 static MatchinfoBuffer *fts3MIBufferNew(size_t nElem, const char *zMatchinfo){
209337 MatchinfoBuffer *pRet;
209338 sqlite3_int64 nByte = sizeof(u32) * (2*(sqlite3_int64)nElem + 1)
209339 + SZ_MATCHINFOBUFFER(1);
209340 sqlite3_int64 nStr = strlen(zMatchinfo);
209341
209342 pRet = sqlite3Fts3MallocZero(nByte + nStr+1);
209343 if( pRet ){
209344 pRet->aMI[0] = (u8*)(&pRet->aMI[1]) - (u8*)pRet;
@@ -210205,12 +210407,12 @@
210205 }
210206 sqlite3Fts3ErrMsg(pzErr, "unrecognized matchinfo request: %c", cArg);
210207 return SQLITE_ERROR;
210208 }
210209
210210 static size_t fts3MatchinfoSize(MatchInfo *pInfo, char cArg){
210211 size_t nVal; /* Number of integers output by cArg */
210212
210213 switch( cArg ){
210214 case FTS3_MATCHINFO_NDOC:
210215 case FTS3_MATCHINFO_NPHRASE:
210216 case FTS3_MATCHINFO_NCOL:
@@ -210222,20 +210424,20 @@
210222 case FTS3_MATCHINFO_LCS:
210223 nVal = pInfo->nCol;
210224 break;
210225
210226 case FTS3_MATCHINFO_LHITS:
210227 nVal = (size_t)pInfo->nCol * pInfo->nPhrase;
210228 break;
210229
210230 case FTS3_MATCHINFO_LHITS_BM:
210231 nVal = (size_t)pInfo->nPhrase * ((pInfo->nCol + 31) / 32);
210232 break;
210233
210234 default:
210235 assert( cArg==FTS3_MATCHINFO_HITS );
210236 nVal = (size_t)pInfo->nCol * pInfo->nPhrase * 3;
210237 break;
210238 }
210239
210240 return nVal;
210241 }
@@ -210513,11 +210715,11 @@
210513 }
210514 break;
210515
210516 case FTS3_MATCHINFO_LHITS_BM:
210517 case FTS3_MATCHINFO_LHITS: {
210518 size_t nZero = fts3MatchinfoSize(pInfo, zArg[i]) * sizeof(u32);
210519 memset(pInfo->aMatchinfo, 0, nZero);
210520 rc = fts3ExprLHitGather(pCsr->pExpr, pInfo);
210521 break;
210522 }
210523
@@ -210582,11 +210784,11 @@
210582 ** matchinfo function has been called for this query. In this case
210583 ** allocate the array used to accumulate the matchinfo data and
210584 ** initialize those elements that are constant for every row.
210585 */
210586 if( pCsr->pMIBuffer==0 ){
210587 size_t nMatchinfo = 0; /* Number of u32 elements in match-info */
210588 int i; /* Used to iterate through zArg */
210589
210590 /* Determine the number of phrases in the query */
210591 pCsr->nPhrase = fts3ExprPhraseCount(pCsr->pExpr);
210592 sInfo.nPhrase = pCsr->nPhrase;
@@ -218269,10 +218471,13 @@
218269 ** be because the shadow tables hold erroneous data. */
218270 if( rc==SQLITE_ERROR ){
218271 rc = SQLITE_CORRUPT_VTAB;
218272 RTREE_IS_CORRUPT(pRtree);
218273 }
 
 
 
218274 }else if( pRtree->iNodeSize==sqlite3_blob_bytes(pRtree->pNodeBlob) ){
218275 pNode = (RtreeNode *)sqlite3_malloc64(sizeof(RtreeNode)+pRtree->iNodeSize);
218276 if( !pNode ){
218277 rc = SQLITE_NOMEM;
218278 }else{
@@ -218910,11 +219115,11 @@
218910 i64 iRowid,
218911 int *piIndex
218912 ){
218913 int ii;
218914 int nCell = NCELL(pNode);
218915 assert( nCell<200 );
218916 for(ii=0; ii<nCell; ii++){
218917 if( nodeGetRowid(pRtree, pNode, ii)==iRowid ){
218918 *piIndex = ii;
218919 return SQLITE_OK;
218920 }
@@ -223953,10 +224158,13 @@
223953 if( c>=0xc0 ){ \
223954 c = icuUtf8Trans1[c-0xc0]; \
223955 while( (*zIn & 0xc0)==0x80 ){ \
223956 c = (c<<6) + (0x3f & *(zIn++)); \
223957 } \
 
 
 
223958 }
223959
223960 #define SQLITE_ICU_SKIP_UTF8(zIn) \
223961 assert( *zIn ); \
223962 if( *(zIn++)>=0xc0 ){ \
@@ -225858,20 +226066,30 @@
225858 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
225859 -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
225860 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
225861 -1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
225862 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
 
 
 
 
 
 
 
 
 
225863 };
225864 unsigned int v = 0;
225865 int c;
225866 unsigned char *z = (unsigned char*)*pz;
225867 unsigned char *zStart = z;
225868 while( (c = zValue[0x7f&*(z++)])>=0 ){
225869 v = (v<<6) + c;
 
225870 }
225871 z--;
225872 *pLen -= (int)(z - zStart);
225873 *pz = (char*)z;
225874 return v;
225875 }
225876
225877 #if RBU_ENABLE_DELTA_CKSUM
@@ -225943,23 +226161,24 @@
225943 #if RBU_ENABLE_DELTA_CKSUM
225944 char *zOrigOut = zOut;
225945 #endif
225946
225947 limit = rbuDeltaGetInt(&zDelta, &lenDelta);
225948 if( *zDelta!='\n' ){
225949 /* ERROR: size integer not terminated by "\n" */
225950 return -1;
225951 }
225952 zDelta++; lenDelta--;
225953 while( *zDelta && lenDelta>0 ){
225954 unsigned int cnt, ofst;
225955 cnt = rbuDeltaGetInt(&zDelta, &lenDelta);
 
225956 switch( zDelta[0] ){
225957 case '@': {
225958 zDelta++; lenDelta--;
225959 ofst = rbuDeltaGetInt(&zDelta, &lenDelta);
225960 if( lenDelta>0 && zDelta[0]!=',' ){
225961 /* ERROR: copy command not terminated by ',' */
225962 return -1;
225963 }
225964 zDelta++; lenDelta--;
225965 total += cnt;
@@ -225980,11 +226199,11 @@
225980 total += cnt;
225981 if( total>limit ){
225982 /* ERROR: insert command gives an output larger than predicted */
225983 return -1;
225984 }
225985 if( (int)cnt>lenDelta ){
225986 /* ERROR: insert count exceeds size of delta */
225987 return -1;
225988 }
225989 memcpy(zOut, zDelta, cnt);
225990 zOut += cnt;
@@ -226018,11 +226237,11 @@
226018 }
226019
226020 static int rbuDeltaOutputSize(const char *zDelta, int lenDelta){
226021 int size;
226022 size = rbuDeltaGetInt(&zDelta, &lenDelta);
226023 if( *zDelta!='\n' ){
226024 /* ERROR: size integer not terminated by "\n" */
226025 return -1;
226026 }
226027 return size;
226028 }
@@ -226066,11 +226285,11 @@
226066 if( nOut<0 ){
226067 sqlite3_result_error(context, "corrupt fossil delta", -1);
226068 return;
226069 }
226070
226071 aOut = sqlite3_malloc(nOut+1);
226072 if( aOut==0 ){
226073 sqlite3_result_error_nomem(context);
226074 }else{
226075 nOut2 = rbuDeltaApply(aOrig, nOrig, aDelta, nDelta, aOut);
226076 if( nOut2!=nOut ){
@@ -244051,11 +244270,11 @@
244051 ){
244052 HighlightContext ctx;
244053 int rc = SQLITE_OK; /* Return code */
244054 int iCol; /* 1st argument to snippet() */
244055 const char *zEllips; /* 4th argument to snippet() */
244056 int nToken; /* 5th argument to snippet() */
244057 int nInst = 0; /* Number of instance matches this row */
244058 int i; /* Used to iterate through instances */
244059 int nPhrase; /* Number of phrases in query */
244060 unsigned char *aSeen; /* Array of "seen instance" flags */
244061 int iBestCol; /* Column containing best snippet */
@@ -244076,11 +244295,11 @@
244076 iCol = sqlite3_value_int(apVal[0]);
244077 ctx.zOpen = fts5ValueToText(apVal[1]);
244078 ctx.zClose = fts5ValueToText(apVal[2]);
244079 ctx.iRangeEnd = -1;
244080 zEllips = fts5ValueToText(apVal[3]);
244081 nToken = sqlite3_value_int(apVal[4]);
244082
244083 iBestCol = (iCol>=0 ? iCol : 0);
244084 nPhrase = pApi->xPhraseCount(pFts);
244085 aSeen = sqlite3_malloc64(nPhrase);
244086 if( aSeen==0 ){
@@ -246792,11 +247011,11 @@
246792 /* Add an entry to each output position list */
246793 for(i=0; i<pNear->nPhrase; i++){
246794 i64 iPos = a[i].reader.iPos;
246795 Fts5PoslistWriter *pWriter = &a[i].writer;
246796 if( a[i].pOut->n==0 || iPos!=pWriter->iPrev ){
246797 sqlite3Fts5PoslistWriterAppend(a[i].pOut, pWriter, iPos);
246798 }
246799 }
246800
246801 iAdv = 0;
246802 iMin = a[0].reader.iLookahead;
@@ -247743,14 +247962,14 @@
247743 rc = SQLITE_NOMEM;
247744 }else{
247745 memset(pSyn, 0, (size_t)nByte);
247746 pSyn->pTerm = ((char*)pSyn) + sizeof(Fts5ExprTerm) + sizeof(Fts5Buffer);
247747 pSyn->nFullTerm = pSyn->nQueryTerm = nToken;
 
247748 if( pCtx->pConfig->bTokendata ){
247749 pSyn->nQueryTerm = (int)strlen(pSyn->pTerm);
247750 }
247751 memcpy(pSyn->pTerm, pToken, nToken);
247752 pSyn->pSynonym = pPhrase->aTerm[pPhrase->nTerm-1].pSynonym;
247753 pPhrase->aTerm[pPhrase->nTerm-1].pSynonym = pSyn;
247754 }
247755 }else{
247756 Fts5ExprTerm *pTerm;
@@ -249595,11 +249814,11 @@
249595 ** + 1 byte for a "new column" byte,
249596 ** + 3 bytes for a new column number (16-bit max) as a varint,
249597 ** + 5 bytes for the new position offset (32-bit max).
249598 */
249599 if( (p->nAlloc - p->nData) < (9 + 4 + 1 + 3 + 5) ){
249600 sqlite3_int64 nNew = p->nAlloc * 2;
249601 Fts5HashEntry *pNew;
249602 Fts5HashEntry **pp;
249603 pNew = (Fts5HashEntry*)sqlite3_realloc64(p, nNew);
249604 if( pNew==0 ) return SQLITE_NOMEM;
249605 pNew->nAlloc = (int)nNew;
@@ -251029,11 +251248,11 @@
251029 }else{
251030 i += fts5GetVarint32(&pData[i], pLvl->nMerge);
251031 i += fts5GetVarint32(&pData[i], nTotal);
251032 if( nTotal<pLvl->nMerge ) rc = FTS5_CORRUPT;
251033 pLvl->aSeg = (Fts5StructureSegment*)sqlite3Fts5MallocZero(&rc,
251034 nTotal * sizeof(Fts5StructureSegment)
251035 );
251036 nSegment -= nTotal;
251037 }
251038
251039 if( rc==SQLITE_OK ){
@@ -251558,19 +251777,20 @@
251558 assert( pLvl->bEof==0 );
251559 if( iOff<=pLvl->iFirstOff ){
251560 pLvl->bEof = 1;
251561 }else{
251562 u8 *a = pLvl->pData->p;
 
251563
251564 pLvl->iOff = 0;
251565 fts5DlidxLvlNext(pLvl);
251566 while( 1 ){
251567 int nZero = 0;
251568 int ii = pLvl->iOff;
251569 u64 delta = 0;
251570
251571 while( a[ii]==0 ){
251572 nZero++;
251573 ii++;
251574 }
251575 ii += sqlite3Fts5GetVarint(&a[ii], &delta);
251576
@@ -251985,11 +252205,11 @@
251985 fts5DataRelease(pIter->pLeaf);
251986 pIter->pLeaf = 0;
251987 while( p->rc==SQLITE_OK && pIter->iLeafPgno>pIter->iTermLeafPgno ){
251988 Fts5Data *pNew;
251989 pIter->iLeafPgno--;
251990 pNew = fts5DataRead(p, FTS5_SEGMENT_ROWID(
251991 pIter->pSeg->iSegid, pIter->iLeafPgno
251992 ));
251993 if( pNew ){
251994 /* iTermLeafOffset may be equal to szLeaf if the term is the last
251995 ** thing on the page - i.e. the first rowid is on the following page.
@@ -253419,12 +253639,11 @@
253419 }
253420 }
253421
253422 do {
253423 while( i<nChunk && pChunk[i]!=0x01 ){
253424 while( pChunk[i] & 0x80 ) i++;
253425 i++;
253426 }
253427 if( pCtx->eState ){
253428 fts5BufferSafeAppendBlob(pCtx->pBuf, &pChunk[iStart], i-iStart);
253429 }
253430 if( i<nChunk ){
@@ -255163,10 +255382,15 @@
255163 int iSOP; /* Start-Of-Position-list */
255164 if( pSeg->iLeafPgno==pSeg->iTermLeafPgno ){
255165 iStart = pSeg->iTermLeafOffset;
255166 }else{
255167 iStart = fts5GetU16(&aPg[0]);
 
 
 
 
 
255168 }
255169
255170 iSOP = iStart + fts5GetVarint(&aPg[iStart], &iDelta);
255171 assert_nc( iSOP<=pSeg->iLeafOffset );
255172
@@ -257849,12 +258073,12 @@
257849 int *pnOut, /* OUT: Number of output pages */
257850 Fts5Data ***papOut /* OUT: Output hash pages */
257851 ){
257852 const int MINSLOT = 32;
257853 int nSlotPerPage = MAX(MINSLOT, (p->pConfig->pgsz - 8) / szKey);
257854 int nSlot = 0; /* Number of slots in each output page */
257855 int nOut = 0;
257856
257857 /* Figure out how many output pages (nOut) and how many slots per
257858 ** page (nSlot). There are three possibilities:
257859 **
257860 ** 1. The hash table does not yet exist. In this case the new hash
@@ -257875,27 +258099,30 @@
257875 /* Case 1. */
257876 nOut = 1;
257877 nSlot = MINSLOT;
257878 }else if( pSeg->nPgTombstone==1 ){
257879 /* Case 2. */
257880 int nElem = (int)fts5GetU32(&pData1->p[4]);
257881 assert( pData1 && iPg1==0 );
257882 nOut = 1;
257883 nSlot = MAX(nElem*4, MINSLOT);
257884 if( nSlot>nSlotPerPage ) nOut = 0;
 
 
 
257885 }
257886 if( nOut==0 ){
257887 /* Case 3. */
257888 nOut = (pSeg->nPgTombstone * 2 + 1);
257889 nSlot = nSlotPerPage;
257890 }
257891
257892 /* Allocate the required array and output pages */
257893 while( 1 ){
257894 int res = 0;
257895 int ii = 0;
257896 int szPage = 0;
257897 Fts5Data **apOut = 0;
257898
257899 /* Allocate space for the new hash table */
257900 assert( nSlot>=MINSLOT );
257901 apOut = (Fts5Data**)sqlite3Fts5MallocZero(&p->rc, sizeof(Fts5Data*) * nOut);
@@ -258429,11 +258656,11 @@
258429 ){
258430
258431 /* Check any rowid-less pages that occur before the current leaf. */
258432 for(iPg=iPrevLeaf+1; iPg<fts5DlidxIterPgno(pDlidx); iPg++){
258433 iKey = FTS5_SEGMENT_ROWID(iSegid, iPg);
258434 pLeaf = fts5DataRead(p, iKey);
258435 if( pLeaf ){
258436 if( fts5LeafFirstRowidOff(pLeaf)!=0 ) FTS5_CORRUPT_ROWID(p, iKey);
258437 fts5DataRelease(pLeaf);
258438 }
258439 }
@@ -258440,11 +258667,11 @@
258440 iPrevLeaf = fts5DlidxIterPgno(pDlidx);
258441
258442 /* Check that the leaf page indicated by the iterator really does
258443 ** contain the rowid suggested by the same. */
258444 iKey = FTS5_SEGMENT_ROWID(iSegid, iPrevLeaf);
258445 pLeaf = fts5DataRead(p, iKey);
258446 if( pLeaf ){
258447 i64 iRowid;
258448 int iRowidOff = fts5LeafFirstRowidOff(pLeaf);
258449 ASSERT_SZLEAF_OK(pLeaf);
258450 if( iRowidOff>=pLeaf->szLeaf ){
@@ -263040,11 +263267,11 @@
263040 int nArg, /* Number of args */
263041 sqlite3_value **apUnused /* Function arguments */
263042 ){
263043 assert( nArg==0 );
263044 UNUSED_PARAM2(nArg, apUnused);
263045 sqlite3_result_text(pCtx, "fts5: 2026-05-30 10:24:03 7487a1c59d3aaea9f8b2569dca76bbccf21948b1e7bd8a1d841e04382db696f4", -1, SQLITE_TRANSIENT);
263046 }
263047
263048 /*
263049 ** Implementation of fts5_locale(LOCALE, TEXT) function.
263050 **
263051
--- extsrc/sqlite3.c
+++ extsrc/sqlite3.c
@@ -16,11 +16,11 @@
16 ** if you want a wrapper to interface SQLite with your choice of programming
17 ** language. The code for the "sqlite3" command-line shell is also in a
18 ** separate file. This file contains only code for the core SQLite library.
19 **
20 ** The content in this amalgamation comes from Fossil check-in
21 ** 3f3fb9b638f59ad982beafb7c117f24ddd3d with changes in files:
22 **
23 **
24 */
25 #ifndef SQLITE_AMALGAMATION
26 #define SQLITE_CORE 1
@@ -467,14 +467,14 @@
467 ** [sqlite3_libversion_number()], [sqlite3_sourceid()],
468 ** [sqlite_version()] and [sqlite_source_id()].
469 */
470 #define SQLITE_VERSION "3.54.0"
471 #define SQLITE_VERSION_NUMBER 3054000
472 #define SQLITE_SOURCE_ID "2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06"
473 #define SQLITE_SCM_BRANCH "trunk"
474 #define SQLITE_SCM_TAGS ""
475 #define SQLITE_SCM_DATETIME "2026-06-16T13:43:08.110Z"
476
477 /*
478 ** CAPI3REF: Run-Time Library Version Numbers
479 ** KEYWORDS: sqlite3_version sqlite3_sourceid
480 **
@@ -4696,11 +4696,12 @@
4696 ** <dd>The maximum number of columns in a table definition or in the
4697 ** result set of a [SELECT] or the maximum number of columns in an index
4698 ** or in an ORDER BY or GROUP BY clause.</dd>)^
4699 **
4700 ** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4701 ** <dd>The maximum depth of the parse tree on any expression and
4702 ** the maximum nesting depth for subqueries and VIEWs</dd>)^
4703 **
4704 ** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
4705 ** <dd>The maximum depth of the LALR(1) parser stack used to analyze
4706 ** input SQL statements.</dd>)^
4707 **
@@ -4727,11 +4728,12 @@
4728 ** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
4729 ** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
4730 ** <dd>The maximum index number of any [parameter] in an SQL statement.)^
4731 **
4732 ** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4733 ** <dd>The maximum depth of recursion for triggers, and the maximum
4734 ** nesting depth for separate triggers.</dd>)^
4735 **
4736 ** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
4737 ** <dd>The maximum number of auxiliary worker threads that a single
4738 ** [prepared statement] may start.</dd>)^
4739 ** </dl>
@@ -11533,12 +11535,23 @@
11535 ** reopen S as an in-memory database based on the serialization
11536 ** contained in P. If S is a NULL pointer, the main database is
11537 ** used. The serialized database P is N bytes in size. M is the size
11538 ** of the buffer P, which might be larger than N. If M is larger than
11539 ** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11540 ** SQLite is permitted to add content to the in-memory database, in
11541 ** page-sized chunks, as long as the total size does not exceed M bytes.
11542 **
11543 ** The parameter M must be greater than or equal to N. Ideally, M
11544 ** should have a value which is N+(512&times;K)+20 where K determines how
11545 ** must extra space is available to hold new content as the database
11546 ** grows. K can be 0 if the database is read-only.
11547 **
11548 ** If the database content in P is malformed in a malicious way then
11549 ** it is possible that SQLite might try to read a few more than N bytes
11550 ** from P. If the veracity of the database content P is uncertain,
11551 ** then applications are advised to allocate about 20 extra bytes on
11552 ** the end of the P buffer to avoid a memory error.
11553 **
11554 ** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
11555 ** invoke sqlite3_free() on the serialization buffer when the database
11556 ** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
11557 ** SQLite will try to increase the buffer size using sqlite3_realloc64()
@@ -15809,10 +15822,17 @@
15822 */
15823 #ifndef offsetof
15824 # define offsetof(ST,M) ((size_t)((char*)&((ST*)0)->M - (char*)0))
15825 #endif
15826
15827 /*
15828 ** sizeof64() is like sizeof(), but always returns a 64-bit value, even
15829 ** on 32-bit builds. This can help to avoid overflow by ensuring 64-bit
15830 ** arithmetic is used consistently in both 32-bit and 64-bit builds.
15831 */
15832 #define sizeof64(X) ((sqlite3_int64)sizeof(X))
15833
15834 /*
15835 ** Work around C99 "flex-array" syntax for pre-C99 compilers, so as
15836 ** to avoid complaints from -fsanitize=strict-bounds.
15837 */
15838 #if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)
@@ -20928,10 +20948,11 @@
20948 int nTab; /* Number of previously allocated VDBE cursors */
20949 int nMem; /* Number of memory cells used so far */
20950 int szOpAlloc; /* Bytes of memory space allocated for Vdbe.aOp[] */
20951 int iSelfTab; /* Table associated with an index on expr, or negative
20952 ** of the base register during check-constraint eval */
20953 int nNestSel; /* Number of nested SELECT statements and/or VIEWs */
20954 int nLabel; /* The *negative* of the number of labels used */
20955 int nLabelAlloc; /* Number of slots in aLabel */
20956 int *aLabel; /* Space to hold the labels */
20957 ExprList *pConstExpr;/* Constant expressions */
20958 IndexedExpr *pIdxEpr;/* List of expressions used by active indexes */
@@ -33389,12 +33410,12 @@
33410 if( flag_alternateform ){
33411 /* For %#q, do unistr()-style backslash escapes for
33412 ** all control characters, and for backslash itself.
33413 ** For %#Q, do the same but only if there is at least
33414 ** one control character. */
33415 i64 nBack = 0;
33416 i64 nCtrl = 0;
33417 for(k=0; k<i; k++){
33418 if( escarg[k]=='\\' ){
33419 nBack++;
33420 }else if( ((u8*)escarg)[k]<=0x1f ){
33421 nCtrl++;
@@ -39255,10 +39276,26 @@
39276 ******************************************************************************
39277 **
39278 ** This file contains an experimental VFS layer that operates on a
39279 ** Key/Value storage engine where both keys and values must be pure
39280 ** text.
39281 **
39282 ** DEBUG AND TEST
39283 **
39284 ** For testing on Unix, compile using:
39285 **
39286 ** make clean sqlite3d CFLAGS='-DSQLITE_OS_KV_OPTIONAL'
39287 **
39288 ** Then start up a shell using something like:
39289 **
39290 ** ./sqlite3d 'file:dbname?vfs=kvvfs'
39291 **
39292 ** Each K/V entry is stored in a separate file in the working
39293 ** directory that has a name like "kvvfs-dbname-*". Due to limitations
39294 ** on the key size, the name of the database must be very short - just
39295 ** a few characters. If the database name is too long, the VFS will
39296 ** malfunction and you will get SQLITE_CORRUPT errors.
39297 */
39298 /* #include <sqliteInt.h> */
39299 #if SQLITE_OS_KV || (SQLITE_OS_UNIX && defined(SQLITE_OS_KV_OPTIONAL))
39300
39301 /*****************************************************************************
@@ -39707,16 +39744,18 @@
39744 }
39745 if( j+n>nOut ) return -1;
39746 memset(&aOut[j], 0, n);
39747 j += n;
39748 if( c==0 || mult==1 ) break; /* progress stalled if mult==1 */
39749 }else if( j<nOut ){
39750 aOut[j] = c<<4;
39751 c = kvvfsHexValue[aIn[++i]];
39752 if( c<0 ) return -1 /* hex bytes are always in pairs */;
39753 aOut[j++] += c;
39754 i++;
39755 }else{
39756 return -1;
39757 }
39758 }
39759 return j;
39760 }
39761
@@ -40138,10 +40177,22 @@
40177 }else{
40178 pFile->isJournal = 0;
40179 pFile->base.pMethods = &kvvfs_db_io_methods;
40180 }
40181 if( !pFile->zClass ){
40182 #ifdef SQLITE_WASM
40183 if( strlen(zName) >= (KVRECORD_KEY_SZ
40184 - 6 /* "kvvfs-" */
40185 - 11 /* "-##########" */) ){
40186 return SQLITE_CANTOPEN;
40187 }
40188 #else
40189 if( 0!=strcmp(zName, "local") && 0!=strcmp(zName, "session") ){
40190 /* Historical naming restriction which journaling depends on. */
40191 return SQLITE_CANTOPEN;
40192 }
40193 #endif
40194 pFile->zClass = zName;
40195 }
40196 pFile->aData = sqlite3_malloc64(SQLITE_KVOS_SZ);
40197 if( pFile->aData==0 ){
40198 return SQLITE_NOMEM;
@@ -52170,15 +52221,33 @@
52221 # define sqlite3_win_test_unc_locking 0
52222 #endif
52223
52224 /*
52225 ** Return true if the string passed as the only argument is likely
52226 ** to be a UNC path. Return false if note.
52227 **
52228 ** Return true if:
52229 **
52230 ** (1) The name begins with "\\"
52231 ** (2) But does not begin with "\\?\C:\" where C can be any alphabetic
52232 ** character.
52233 **
52234 ** For testing, also return true in all cases if the global variable
52235 ** sqlite3_win_test_unc_locking is true.
52236 */
52237 static int winIsUNCPath(const char *zFile){
52238 if( zFile[0]=='\\' && zFile[1]=='\\' ){
52239 if( zFile[2]=='?'
52240 && zFile[3]=='\\'
52241 && sqlite3Isalpha(zFile[4])
52242 && zFile[5]==':'
52243 && winIsDirSep(zFile[6])
52244 ){
52245 return sqlite3_win_test_unc_locking;
52246 }else{
52247 return 1;
52248 }
52249 }
52250 return sqlite3_win_test_unc_locking;
52251 }
52252
52253 /*
@@ -56926,26 +56995,28 @@
56995 szBulk = -1024 * (i64)pcache1.nInitPage;
56996 }
56997 if( szBulk > pCache->szAlloc*(i64)pCache->nMax ){
56998 szBulk = pCache->szAlloc*(i64)pCache->nMax;
56999 }
57000 if( szBulk>=pCache->szAlloc ){
57001 zBulk = pCache->pBulk = sqlite3Malloc( szBulk );
57002 sqlite3EndBenignMalloc();
57003 if( zBulk ){
57004 int nBulk = sqlite3MallocSize(zBulk)/pCache->szAlloc;
57005 do{
57006 PgHdr1 *pX = (PgHdr1*)&zBulk[pCache->szPage];
57007 pX->page.pBuf = zBulk;
57008 pX->page.pExtra = (u8*)pX + ROUND8(sizeof(*pX));
57009 assert( EIGHT_BYTE_ALIGNMENT( pX->page.pExtra ) );
57010 pX->isBulkLocal = 1;
57011 pX->isAnchor = 0;
57012 pX->pNext = pCache->pFree;
57013 pX->pLruPrev = 0; /* Initializing this saves a valgrind error */
57014 pCache->pFree = pX;
57015 zBulk += pCache->szAlloc;
57016 }while( --nBulk );
57017 }
57018 }
57019 return pCache->pFree!=0;
57020 }
57021
57022 /*
@@ -59839,73 +59910,84 @@
59910 #define pager_set_pagehash(X)
59911 #define CHECK_PAGE(x)
59912 #endif /* SQLITE_CHECK_PAGES */
59913
59914 /*
59915 ** Free a buffer allocated by the readSuperJournal() function.
59916 */
59917 static void freeSuperJournal(char *zSuper){
59918 if( zSuper ){
59919 sqlite3_free(&zSuper[-4]);
59920 }
59921 }
59922
59923 /*
59924 ** Parameter pJrnl is a file-handle open on a journal file. This function
59925 ** attempts to read a super-journal file name from the end of the journal
59926 ** file. If successful, it sets output parameter (*pzSuper) to point to a
59927 ** buffer containing the super-journal name as a nul-terminated string.
59928 ** The caller is responsible for freeing the buffer using freeSuperJournal().
59929 **
59930 ** Refer to comments above writeSuperJournal() for the format used to store
59931 ** a super-journal file name at the end of a journal file.
59932 **
59933 ** Parameter nSuper is passed the maximum allowable size of the super journal
59934 ** name in bytes. If the super-journal name in the journal is longer than
59935 ** nSuper bytes (including a nul-terminator), then this is handled as if no
59936 ** super-journal name were present in the journal.
59937 **
59938 ** If there is no super-journal name at the end of pJrnl, (*pzSuper) is
59939 ** set to 0 and SQLITE_OK is returned. Or, if an error occurs while reading
59940 ** the super-journal name, an SQLite error code is returned and (*pzSuper)
59941 ** is set to 0.
59942 */
59943 static int readSuperJournal(sqlite3_file *pJrnl, u64 nSuper, char **pzSuper){
59944 int rc; /* Return code */
59945 u32 len; /* Length in bytes of super-journal name */
59946 i64 szJ; /* Total size in bytes of journal file pJrnl */
59947 u32 cksum; /* MJ checksum value read from journal */
 
59948 unsigned char aMagic[8]; /* A buffer to hold the magic header */
59949 char *zOut = 0;
59950
59951 *pzSuper = 0;
59952 if( SQLITE_OK!=(rc = sqlite3OsFileSize(pJrnl, &szJ))
59953 || szJ<16
59954 || SQLITE_OK!=(rc = read32bits(pJrnl, szJ-16, &len))
59955 || len>=nSuper
59956 || len>szJ-16
59957 || len==0
59958 || SQLITE_OK!=(rc = read32bits(pJrnl, szJ-12, &cksum))
59959 || SQLITE_OK!=(rc = sqlite3OsRead(pJrnl, aMagic, 8, szJ-8))
59960 || memcmp(aMagic, aJournalMagic, 8)
 
59961 ){
59962 return rc;
59963 }
59964
59965 zOut = (char*)sqlite3MallocZero(4 + len + 2);
59966 if( !zOut ){
59967 rc = SQLITE_NOMEM_BKPT;
59968 }else{
59969 zOut = &zOut[4];
59970 if( SQLITE_OK==(rc = sqlite3OsRead(pJrnl, zOut, len, szJ-16-len)) ){
59971 u32 u; /* Unsigned loop counter */
59972 /* See if the checksum matches the super-journal name */
59973 for(u=0; u<len; u++){
59974 cksum -= zOut[u];
59975 }
59976 }
59977 if( rc!=SQLITE_OK || cksum ){
59978 /* If the checksum doesn't add up, then one or more of the disk sectors
59979 ** containing the super-journal filename is corrupted. This means
59980 ** definitely roll back, so just return SQLITE_OK and report a (nul)
59981 ** super-journal filename. */
59982 freeSuperJournal(zOut);
59983 zOut = 0;
59984 }
59985 }
59986
59987 *pzSuper = zOut;
59988 return rc;
59989 }
59990
59991 /*
59992 ** Return the offset of the sector boundary at or immediately
59993 ** following the value in pPager->journalOff, assuming a sector
@@ -61106,13 +61188,11 @@
61188 sqlite3_file *pSuper; /* Malloc'd super-journal file descriptor */
61189 sqlite3_file *pJournal; /* Malloc'd child-journal file descriptor */
61190 char *zSuperJournal = 0; /* Contents of super-journal file */
61191 i64 nSuperJournal; /* Size of super-journal file */
61192 char *zJournal; /* Pointer to one journal within MJ file */
 
61193 char *zFree = 0; /* Free this buffer */
 
61194
61195 /* Allocate space for both the pJournal and pSuper file descriptors.
61196 ** If successful, open the super-journal file for reading.
61197 */
61198 pSuper = (sqlite3_file *)sqlite3MallocZero(2 * (i64)pVfs->szOsFile);
@@ -61131,22 +61211,20 @@
61211 ** sufficient space (in zSuperPtr) to hold the names of super-journal
61212 ** files extracted from regular rollback-journals.
61213 */
61214 rc = sqlite3OsFileSize(pSuper, &nSuperJournal);
61215 if( rc!=SQLITE_OK ) goto delsuper_out;
61216 assert( nSuperJournal>=0 );
61217 zFree = sqlite3Malloc(4 + nSuperJournal + 2);
 
61218 if( !zFree ){
61219 rc = SQLITE_NOMEM_BKPT;
61220 goto delsuper_out;
61221 }else{
61222 assert( nSuperJournal<=0x7fffffff );
61223 }
61224 zFree[0] = zFree[1] = zFree[2] = zFree[3] = 0;
61225 zSuperJournal = &zFree[4];
 
61226 rc = sqlite3OsRead(pSuper, zSuperJournal, (int)nSuperJournal, 0);
61227 if( rc!=SQLITE_OK ) goto delsuper_out;
61228 zSuperJournal[nSuperJournal] = 0;
61229 zSuperJournal[nSuperJournal+1] = 0;
61230
@@ -61156,10 +61234,12 @@
61234 rc = sqlite3OsAccess(pVfs, zJournal, SQLITE_ACCESS_EXISTS, &exists);
61235 if( rc!=SQLITE_OK ){
61236 goto delsuper_out;
61237 }
61238 if( exists ){
61239 char *zSuperPtr = 0;
61240
61241 /* One of the journals pointed to by the super-journal exists.
61242 ** Open it and check if it points at the super-journal. If
61243 ** so, return without deleting the super-journal file.
61244 ** NB: zJournal is really a MAIN_JOURNAL. But call it a
61245 ** SUPER_JOURNAL here so that the VFS will not send the zJournal
@@ -61170,17 +61250,19 @@
61250 rc = sqlite3OsOpen(pVfs, zJournal, pJournal, flags, 0);
61251 if( rc!=SQLITE_OK ){
61252 goto delsuper_out;
61253 }
61254
61255 rc = readSuperJournal(pJournal, 1+(u64)pVfs->mxPathname, &zSuperPtr);
61256 sqlite3OsClose(pJournal);
61257 if( rc!=SQLITE_OK ){
61258 assert( zSuperPtr==0 );
61259 goto delsuper_out;
61260 }
61261
61262 c = zSuperPtr!=0 && strcmp(zSuperPtr, zSuper)==0;
61263 freeSuperJournal(zSuperPtr);
61264 if( c ){
61265 /* We have a match. Do not delete the super-journal file. */
61266 goto delsuper_out;
61267 }
61268 }
@@ -61391,23 +61473,15 @@
61473
61474 /* Read the super-journal name from the journal, if it is present.
61475 ** If a super-journal file name is specified, but the file is not
61476 ** present on disk, then the journal is not hot and does not need to be
61477 ** played back.
61478 */
61479 rc = readSuperJournal(pPager->jfd, 1+(i64)pPager->pVfs->mxPathname, &zSuper);
61480 if( rc==SQLITE_OK && zSuper ){
 
 
 
 
 
 
 
61481 rc = sqlite3OsAccess(pVfs, zSuper, SQLITE_ACCESS_EXISTS, &res);
61482 }
 
61483 if( rc!=SQLITE_OK || !res ){
61484 goto end_playback;
61485 }
61486 pPager->journalOff = 0;
61487 needPagerReset = isHot;
@@ -61532,34 +61606,24 @@
61606 ** problems for other processes at some point in the future. So, just
61607 ** in case this has happened, clear the changeCountDone flag now.
61608 */
61609 pPager->changeCountDone = pPager->tempFile;
61610
 
 
 
 
 
 
 
 
 
61611 if( rc==SQLITE_OK
61612 && (pPager->eState>=PAGER_WRITER_DBMOD || pPager->eState==PAGER_OPEN)
61613 ){
61614 rc = sqlite3PagerSync(pPager, 0);
61615 }
61616 if( rc==SQLITE_OK ){
61617 rc = pager_end_transaction(pPager, zSuper!=0, 0);
61618 testcase( rc!=SQLITE_OK );
61619 }
61620 if( rc==SQLITE_OK && zSuper && res ){
61621 /* If there was a super-journal and this routine will return success,
61622 ** see if it is possible to delete the super-journal.
61623 */
61624 assert( memcmp(&zSuper[-4], "\0\0\0\0", 4)==0 );
 
61625 rc = pager_delsuper(pPager, zSuper);
61626 testcase( rc!=SQLITE_OK );
61627 }
61628 if( isHot && nPlayback ){
61629 sqlite3_log(SQLITE_NOTICE_RECOVER_ROLLBACK, "recovered %d pages from %s",
@@ -61568,10 +61632,11 @@
61632
61633 /* The Pager.sectorSize variable may have been updated while rolling
61634 ** back a journal created by a process with a different sector size
61635 ** value. Reset it to the correct value for this process.
61636 */
61637 freeSuperJournal(zSuper);
61638 setSectorSize(pPager);
61639 return rc;
61640 }
61641
61642
@@ -67426,10 +67491,16 @@
67491 */
67492 pgno = sqlite3Get4byte(&aFrame[0]);
67493 if( pgno==0 ){
67494 return 0;
67495 }
67496
67497 /* Need a valid page size
67498 */
67499 if( !pWal->szPage ){
67500 return 0;
67501 }
67502
67503 /* A frame is only valid if a checksum of the WAL header,
67504 ** all prior frames, the first 16 bytes of this frame-header,
67505 ** and the frame-data matches the checksum in the last 8
67506 ** bytes of this frame-header.
@@ -69281,11 +69352,11 @@
69352 goto begin_unreliable_shm_out;
69353 }
69354
69355 /* Allocate a buffer to read frames into */
69356 assert( (pWal->szPage & (pWal->szPage-1))==0 );
69357 assert( (pWal->szPage>=512 && pWal->szPage<=65536) || pWal->szPage==0 );
69358 szFrame = pWal->szPage + WAL_FRAME_HDRSIZE;
69359 aFrame = (u8 *)sqlite3_malloc64(szFrame);
69360 if( aFrame==0 ){
69361 rc = SQLITE_NOMEM_BKPT;
69362 goto begin_unreliable_shm_out;
@@ -83086,10 +83157,15 @@
83157 if( pc+info.nSize>usableSize ){
83158 checkAppendMsg(pCheck, "Extends off end of page");
83159 doCoverageCheck = 0;
83160 continue;
83161 }
83162 if( info.nPayload && info.pPayload[0]<2 ){
83163 checkAppendMsg(pCheck, "Bad cell header size");
83164 doCoverageCheck = 0;
83165 continue;
83166 }
83167
83168 /* Check for integer primary key out of range */
83169 if( pPage->intKey ){
83170 if( keyCanBeEqual ? (info.nKey > maxKey) : (info.nKey >= maxKey) ){
83171 checkAppendMsg(pCheck, "Rowid %lld out of order", info.nKey);
@@ -84596,12 +84672,12 @@
84672 /* Work-around for GCC bug or bugs:
84673 ** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=96270
84674 ** https://gcc.gnu.org/bugzilla/show_bug.cgi?id=114659
84675 ** The problem appears to be fixed in GCC 15 */
84676 i64 x;
84677 assert( (sqlite3Config.bSmallMalloc!=0xee)*8==sizeof(x) );
84678 memcpy(&x, (char*)&p->u.i, (sqlite3Config.bSmallMalloc!=0xee)*8);
84679 p->n = sqlite3Int64ToText(x, zBuf);
84680 #else
84681 p->n = sqlite3Int64ToText(p->u.i, zBuf);
84682 #endif
84683 if( p->flags & MEM_IntReal ){
@@ -95448,11 +95524,11 @@
95524 QueryPerformanceCounter(&tm);
95525 return (sqlite3_uint64)tm.QuadPart;
95526 }
95527
95528 #elif !defined(__STRICT_ANSI__) && defined(__GNUC__) && \
95529 (defined(i586) || defined(__i586__) || defined(_M_IX86))
95530
95531 __inline__ sqlite_uint64 sqlite3Hwtime(void){
95532 unsigned int lo, hi;
95533 __asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
95534 return (sqlite_uint64)hi << 32 | lo;
@@ -103037,11 +103113,11 @@
103113 if( pFrame ) break;
103114 }
103115
103116 if( p->nFrame>=db->aLimit[SQLITE_LIMIT_TRIGGER_DEPTH] ){
103117 rc = SQLITE_ERROR;
103118 sqlite3VdbeError(p, "triggers nested too deep");
103119 goto abort_due_to_error;
103120 }
103121
103122 /* Register pRt is used to store the memory required to save the state
103123 ** of the current program, and the memory required at runtime to execute
@@ -110164,11 +110240,11 @@
110240 /*
110241 ** cnt==0 means there was not match.
110242 ** cnt>1 means there were two or more matches.
110243 **
110244 ** cnt==0 is always an error. cnt>1 is often an error, but might
110245 ** be multiple matches for a NATURAL OUTER JOIN or a OUTER JOIN USING.
110246 */
110247 assert( pFJMatch==0 || cnt>0 );
110248 assert( !ExprHasProperty(pExpr, EP_xIsSelect|EP_IntValue) );
110249 if( cnt!=1 ){
110250 const char *zErr;
@@ -110247,12 +110323,21 @@
110323 pExpr->op = eNewExprOp;
110324 lookupname_end:
110325 if( cnt==1 ){
110326 assert( pNC!=0 );
110327 #ifndef SQLITE_OMIT_AUTHORIZATION
110328 if( db->xAuth ){
110329 if( pFJMatch ){
110330 assert( pExpr->op==TK_FUNCTION );
110331 assert( sqlite3_stricmp(pExpr->u.zToken,"coalesce")==0 );
110332 assert( pExpr->x.pList==pFJMatch );
110333 assert( pFJMatch->nExpr>0 );
110334 pExpr = pFJMatch->a[0].pExpr;
110335 }
110336 if( pExpr->op==TK_COLUMN || pExpr->op==TK_TRIGGER ){
110337 sqlite3AuthRead(pParse, pExpr, pSchema, pNC->pSrcList);
110338 }
110339 }
110340 #endif
110341 /* Increment the nRef value on all name contexts from TopNC up to
110342 ** the point where the name matched. */
110343 for(;;){
@@ -115930,10 +116015,11 @@
116015 if( i!=nVector ){
116016 /* Need to reorder the LHS fields according to aiMap */
116017 int rLhsOrig = rLhs;
116018 rLhs = sqlite3GetTempRange(pParse, nVector);
116019 for(i=0; i<nVector; i++){
116020 testcase( aiMap[i]!=i );
116021 sqlite3VdbeAddOp3(v, OP_Copy, rLhsOrig+i, rLhs+aiMap[i], 0);
116022 }
116023 sqlite3ReleaseTempReg(pParse, rLhsOrig);
116024 }
116025 }
@@ -115950,11 +116036,12 @@
116036 }
116037 for(i=0; i<nVector; i++){
116038 Expr *p = sqlite3VectorFieldSubexpr(pExpr->pLeft, i);
116039 if( pParse->nErr ) goto sqlite3ExprCodeIN_oom_error;
116040 if( sqlite3ExprCanBeNull(p) ){
116041 testcase( aiMap[i]!=i );
116042 sqlite3VdbeAddOp2(v, OP_IsNull, rLhs+aiMap[i], destStep2);
116043 VdbeCoverage(v);
116044 }
116045 }
116046
116047 /* Step 3. The LHS is now known to be non-NULL. Do the binary search
@@ -116024,13 +116111,23 @@
116111 for(i=0; i<nVector; i++){
116112 Expr *p;
116113 CollSeq *pColl;
116114 int r3 = sqlite3GetTempReg(pParse);
116115 p = sqlite3VectorFieldSubexpr(pLeft, i);
116116 if( ExprUseXSelect(pExpr) ){
116117 Expr *pRhs = pExpr->x.pSelect->pEList->a[i].pExpr;
116118 pColl = sqlite3BinaryCompareCollSeq(pParse, p, pRhs);
116119 }else{
116120 /* If the RHS of the IN(...) expression are scalar expressions, do
116121 ** not consider their collation sequences. The documentation says
116122 ** "The collating sequence used for expressions of the form "x IN (y, z,
116123 ** ...)" is the collating sequence of x.". */
116124 pColl = sqlite3ExprCollSeq(pParse, p);
116125 }
116126 testcase( aiMap[i]!=i );
116127 sqlite3VdbeAddOp3(v, OP_Column, iTab, aiMap[i], r3);
116128 sqlite3VdbeAddOp4(v, OP_Ne, rLhs+aiMap[i], destNotNull, r3,
116129 (void*)pColl, P4_COLLSEQ);
116130 VdbeCoverage(v);
116131 sqlite3ReleaseTempReg(pParse, r3);
116132 }
116133 sqlite3VdbeAddOp2(v, OP_Goto, 0, destIfNull);
@@ -124365,21 +124462,21 @@
124462 }else{
124463 nIdxCol = pIdx->nColumn;
124464 }
124465 pIdx->nSampleCol = nIdxCol;
124466 pIdx->mxSample = nSample;
124467 nByte = ROUND8(sizeof64(IndexSample) * nSample);
124468 nByte += sizeof64(tRowcnt) * nIdxCol * 3 * nSample;
124469 nByte += nIdxCol * sizeof64(tRowcnt); /* Space for Index.aAvgEq[] */
124470
124471 pIdx->aSample = sqlite3DbMallocZero(db, nByte);
124472 if( pIdx->aSample==0 ){
124473 sqlite3_finalize(pStmt);
124474 return SQLITE_NOMEM_BKPT;
124475 }
124476 pPtr = (u8*)pIdx->aSample;
124477 pPtr += ROUND8(nSample*sizeof64(pIdx->aSample[0]));
124478 pSpace = (tRowcnt*)pPtr;
124479 assert( EIGHT_BYTE_ALIGNMENT( pSpace ) );
124480 pIdx->aAvgEq = pSpace; pSpace += nIdxCol;
124481 pIdx->pTable->tabFlags |= TF_HasStat4;
124482 for(i=0; i<nSample; i++){
@@ -133231,13 +133328,22 @@
133328 x.nUsed = 0;
133329 x.apArg = argv+1;
133330 sqlite3StrAccumInit(&str, db, 0, 0, db->aLimit[SQLITE_LIMIT_LENGTH]);
133331 str.printfFlags = SQLITE_PRINTF_SQLFUNC;
133332 sqlite3_str_appendf(&str, zFormat, &x);
133333 if( str.accError==SQLITE_OK ){
133334 n = str.nChar;
133335 sqlite3_result_text(context, sqlite3StrAccumFinish(&str), n,
133336 SQLITE_DYNAMIC);
133337 }else{
133338 if( str.accError==SQLITE_NOMEM ){
133339 sqlite3_result_error_nomem(context);
133340 }else{
133341 sqlite3_result_error_toobig(context);
133342 }
133343 sqlite3_str_reset(&str);
133344 }
133345 }
133346 }
133347
133348 /*
133349 ** Implementation of the substr() function.
@@ -134405,11 +134511,11 @@
134511 int nStr; /* Size of zStr */
134512 int nPattern; /* Size of zPattern */
134513 int nRep; /* Size of zRep */
134514 i64 nOut; /* Maximum size of zOut */
134515 int loopLimit; /* Last zStr[] that might match zPattern[] */
134516 i64 i, j; /* Loop counters */
134517 unsigned cntExpand; /* Number zOut expansions */
134518 sqlite3 *db = sqlite3_context_db_handle(context);
134519
134520 assert( argc==3 );
134521 UNUSED_PARAMETER(argc);
@@ -135859,57 +135965,95 @@
135965 ** from the standard library because:
135966 **
135967 ** (1) To avoid a dependency on qsort()
135968 ** (2) To avoid the function call to the comparison routine for each
135969 ** comparison.
135970 **
135971 ** If parameter iReq is non-negative, then the caller will only access
135972 ** elements a[iReq] and a[iReq+1] (if it exists) of the sorted array and
135973 ** so it is not necessary to position any other elements. Or if iReq is
135974 ** negative, then the final array must be fully sorted.
135975 */
135976 static void percentSort(
135977 double *a, /* Array to sort */
135978 unsigned int n, /* Number of elements in array a[] */
135979 int iReq /* Element caller cares about (or -ve) */
135980 ){
135981 int iLt; /* Entries before a[iLt] are less than rPivot */
135982 int iGt; /* Entries at or after a[iGt] are greater than rPivot */
135983 int i; /* Loop counter */
135984 double rPivot; /* The pivot value */
135985
135986 assert( n>=2 );
135987 do{
135988 if( a[0]>a[n-1] ){
135989 SWAP_DOUBLE(a[0],a[n-1])
135990 }
135991 if( n==2 ) return;
135992 iGt = n-1;
135993 i = n/2;
135994 if( a[0]>a[i] ){
135995 SWAP_DOUBLE(a[0],a[i])
135996 }else if( a[i]>a[iGt] ){
135997 SWAP_DOUBLE(a[i],a[iGt])
135998 }
135999 if( n==3 ) return;
136000 rPivot = a[i];
136001 iLt = i = 1;
136002 do{
136003 if( a[i]<rPivot ){
136004 if( i>iLt ) SWAP_DOUBLE(a[i],a[iLt])
136005 iLt++;
136006 i++;
136007 }else if( a[i]>rPivot ){
136008 do{
136009 iGt--;
136010 }while( iGt>i && a[iGt]>rPivot );
136011 SWAP_DOUBLE(a[i],a[iGt])
136012 }else{
136013 i++;
136014 }
136015 }while( i<iGt );
136016
136017 assert( a[iLt]==rPivot );
136018 assert( iGt>iLt );
136019
136020 if( iReq>=0 ){
136021 /* In this case, the only elements that the caller requires sorted into
136022 ** the correct positions are elements a[iReq] and a[iReq+1]. At this
136023 ** point we know that element a[iLt] is in the correct position and
136024 ** all elements smaller than a[iLt] are in the left-hand partition.
136025 ** So if (iReq<iLt), then it is only necessary to sort the left
136026 ** partition.
136027 **
136028 ** If (iReq>=iLt), then elements iReq and iReq+1 are either in the
136029 ** right partition or the equal partition (elements for which
136030 ** iLt<=iElem<iGt). Therefore it is always sufficient to sort only
136031 ** the right partition in this case. */
136032 if( iReq<iLt ){
136033 n = iLt;
136034 }else{
136035 a += iGt;
136036 n -= iGt;
136037 iReq = MAX(0, iReq-iGt);
136038 }
136039 }else{
136040 /* Recurse on the smaller partition only. The smaller partition
136041 ** will hold n/2 or fewer entries, which assures that the stack
136042 ** depth will not exceed O(log(n)), even for pathological cases.
136043 ** Loop without recursion for the larger partition. */
136044 if( iLt>(int)(n/2) ){
136045 if( n-iGt>=2 ) percentSort(a+iGt, n-iGt, -1);
136046 n = iLt;
136047 }else{
136048 if( iLt>=2 ) percentSort(a, iLt, -1);
136049 a += iGt;
136050 n -= iGt;
136051 }
136052 }
136053 }while( n>=2 );
136054 }
136055
136056 /*
136057 ** The "inverse" function for percentile(Y,P) is called to remove a
136058 ** row that was previously inserted by "step".
136059 */
@@ -135939,11 +136083,11 @@
136083 if( percentIsInfinity(y) ){
136084 return;
136085 }
136086 if( p->bSorted==0 ){
136087 assert( p->nUsed>1 );
136088 percentSort(p->a, p->nUsed, -1);
136089 p->bSorted = 1;
136090 }
136091 p->bKeepSorted = 1;
136092
136093 /* Find and remove the row */
@@ -135968,17 +136112,21 @@
136112 double ix, vx;
136113 p = (Percentile*)sqlite3_aggregate_context(pCtx, 0);
136114 if( p==0 ) return;
136115 if( p->a==0 ) return;
136116 if( p->nUsed ){
136117 ix = p->rPct*(p->nUsed-1);
136118 i1 = (unsigned)ix;
136119 if( p->bSorted==0 ){
136120 /* In cases where bIsFinal is non-zero, setting Percentile.bSorted
136121 ** after the percentSort() call here is not technically correct, as
136122 ** the array is not fully sorted. But in this case the object will be
136123 ** freed below anyway, so it doesn't matter. */
136124 assert( p->nUsed>1 );
136125 percentSort(p->a, p->nUsed, (bIsFinal ? (int)i1 : -1));
136126 p->bSorted = 1;
136127 }
 
 
136128 if( settings & 1 ){
136129 vx = p->a[i1];
136130 }else{
136131 i2 = ix==(double)i1 || i1==p->nUsed-1 ? i1 : i1+1;
136132 v1 = p->a[i1];
@@ -150488,10 +150636,17 @@
150636 SQLITE_PRIVATE Table *sqlite3ResultSetOfSelect(Parse *pParse, Select *pSelect, char aff){
150637 Table *pTab;
150638 sqlite3 *db = pParse->db;
150639 u64 savedFlags;
150640
150641 pParse->nNestSel++;
150642 #if SQLITE_MAX_EXPR_DEPTH>0
150643 if( pParse->nNestSel >= db->aLimit[SQLITE_LIMIT_EXPR_DEPTH] ){
150644 sqlite3ErrorMsg(pParse, "VIEWs and/or subqueries nested too deep");
150645 return 0;
150646 }
150647 #endif
150648 savedFlags = db->flags;
150649 db->flags &= ~(u64)SQLITE_FullColNames;
150650 db->flags |= SQLITE_ShortColNames;
150651 sqlite3SelectPrep(pParse, pSelect, 0);
150652 db->flags = savedFlags;
@@ -150509,10 +150664,12 @@
150664 pTab->iPKey = -1;
150665 if( db->mallocFailed ){
150666 sqlite3DeleteTable(db, pTab);
150667 return 0;
150668 }
150669 pParse->nNestSel--;
150670 assert( pParse->nNestSel>=0 );
150671 return pTab;
150672 }
150673
150674 /*
150675 ** Get a VDBE for the given parser context. Create a new one if necessary.
@@ -155479,12 +155636,15 @@
155636 ** Then, if CheckOnCtx.iJoin indicates that this expression is part of an
155637 ** ON clause from that SrcList (i.e. if iJoin is non-zero), check that it
155638 ** does not refer to a table to the right of CheckOnCtx.iJoin. */
155639 do {
155640 SrcList *pSrc = pCtx->pSrc;
155641 int nSrc = pSrc->nSrc;
155642 int iTab = pExpr->iTable;
155643 int ii;
155644 for(ii=0; ii<nSrc && pSrc->a[ii].iCursor!=iTab; ii++){}
155645 if( ii<nSrc ){
155646 if( pCtx->iJoin && iTab>pCtx->iJoin ){
155647 sqlite3ErrorMsg(pWalker->pParse,
155648 "%s references tables to its right",
155649 (pCtx->bFuncArg ? "table-function argument" : "ON clause")
155650 );
@@ -158449,22 +158609,36 @@
158609 Parse *pParse, /* Current parse context */
158610 Trigger *pTrigger, /* Trigger to code */
158611 Table *pTab, /* The table pTrigger is attached to */
158612 int orconf /* ON CONFLICT policy to code trigger program with */
158613 ){
158614 Parse *pTop; /* Top level Parse object */
158615 sqlite3 *db = pParse->db; /* Database handle */
158616 TriggerPrg *pPrg; /* Value to return */
158617 Expr *pWhen = 0; /* Duplicate of trigger WHEN expression */
158618 Vdbe *v; /* Temporary VM */
158619 NameContext sNC; /* Name context for sub-vdbe */
158620 SubProgram *pProgram = 0; /* Sub-vdbe for trigger program */
158621 int iEndTrigger = 0; /* Label to jump to if WHEN is false */
158622 Parse sSubParse; /* Parse context for sub-vdbe */
158623 int nDepth; /* Trigger depth */
158624
158625 /* Ensure that triggers are not chained too deep. This test is linear
158626 ** in the chaining depth, but sensible code ought not be chaining
158627 ** triggers excessively, so that shouldn't be a problem.
158628 */
158629 pTop = pParse;
158630 for(nDepth=0; pTop->pOuterParse; pTop = pTop->pOuterParse, nDepth++){}
158631 if( nDepth>=db->aLimit[SQLITE_LIMIT_TRIGGER_DEPTH] ){
158632 sqlite3ErrorMsg(pParse, "triggers nested too deep");
158633 return 0;
158634 }
158635
158636 pTop = sqlite3ParseToplevel(pParse);
158637 assert( pTrigger->zName==0 || pTab==tableOfTrigger(pTrigger) );
158638 assert( pTop->pVdbe );
158639
158640
158641 /* Allocate the TriggerPrg and SubProgram objects. To ensure that they
158642 ** are freed if an error occurs, link them into the Parse.pTriggerPrg
158643 ** list of the top-level Parse object sooner rather than later. */
158644 pPrg = sqlite3DbMallocZero(db, sizeof(TriggerPrg));
@@ -160462,11 +160636,12 @@
160636 ** So we have to make a copy before passing it down into sqlite3Update() */
160637 pSrc = sqlite3SrcListDup(db, pTop->pUpsertSrc, 0);
160638 /* excluded.* columns of type REAL need to be converted to a hard real */
160639 for(i=0; i<pTab->nCol; i++){
160640 if( pTab->aCol[i].affinity==SQLITE_AFF_REAL ){
160641 int iStorage = pTop->regData + sqlite3TableColumnToStorage(pTab, i);
160642 sqlite3VdbeAddOp1(v, OP_RealAffinity, iStorage);
160643 }
160644 }
160645 sqlite3Update(pParse, pSrc, sqlite3ExprListDup(db,pUpsert->pUpsertSet,0),
160646 sqlite3ExprDup(db,pUpsert->pUpsertWhere,0), OE_Abort, 0, 0, pUpsert);
160647 VdbeNoopComment((v, "End DO UPDATE of UPSERT"));
@@ -165680,10 +165855,11 @@
165855 assert( (pTerm->eOperator & (WO_OR|WO_AND))==0 );
165856 pAlt = sqlite3WhereFindTerm(pWC, iCur, pTerm->u.x.leftColumn, notReady,
165857 WO_EQ|WO_IN|WO_IS, 0);
165858 if( pAlt==0 ) continue;
165859 if( pAlt->wtFlags & (TERM_CODED) ) continue;
165860 if( ExprHasProperty(pAlt->pExpr, EP_Collate) ) continue;
165861 if( (pAlt->eOperator & WO_IN)
165862 && ExprUseXSelect(pAlt->pExpr)
165863 && (pAlt->pExpr->x.pSelect->pEList->nExpr>1)
165864 ){
165865 continue;
@@ -166433,11 +166609,14 @@
166609 ** Mark term iChild as being a child of term iParent
166610 */
166611 static void markTermAsChild(WhereClause *pWC, int iChild, int iParent){
166612 pWC->a[iChild].iParent = iParent;
166613 pWC->a[iChild].truthProb = pWC->a[iParent].truthProb;
166614 assert( pWC->a[iParent].nChild < UMXV(pWC->a[0].nChild) );
166615 pWC->a[iParent].nChild++;
166616 testcase( pWC->a[iParent].nChild == UMXV(pWC->a[0].nChild) );
166617
166618 }
166619
166620 /*
166621 ** Return the N-th AND-connected subterm of pTerm. Or if pTerm is not
166622 ** a conjunction, then return just pTerm when N==0. If N is exceeds
@@ -166872,21 +167051,22 @@
167051 ** 1. The SQLITE_Transitive optimization must be enabled
167052 ** 2. Must be either an == or an IS operator
167053 ** 3. Not originating in the ON clause of an OUTER JOIN
167054 ** 4. The operator is not IS or else the query does not contain RIGHT JOIN
167055 ** 5. The affinities of A and B must be compatible
167056 ** 6. Both operands use the same collating sequence, and they must not
167057 ** use explicit COLLATE clauses.
167058 ** If this routine returns TRUE, that means that the RHS can be substituted
167059 ** for the LHS anyplace else in the WHERE clause where the LHS column occurs.
167060 ** This is an optimization. No harm comes from returning 0. But if 1 is
167061 ** returned when it should not be, then incorrect answers might result.
167062 */
167063 static int termIsEquivalence(Parse *pParse, Expr *pExpr, SrcList *pSrc){
167064 char aff1, aff2;
167065 if( !OptimizationEnabled(pParse->db, SQLITE_Transitive) ) return 0; /* (1) */
167066 if( pExpr->op!=TK_EQ && pExpr->op!=TK_IS ) return 0; /* (2) */
167067 if( ExprHasProperty(pExpr, EP_OuterON|EP_Collate) ) return 0; /* (3) */
167068 assert( pSrc!=0 );
167069 if( pExpr->op==TK_IS
167070 && pSrc->nSrc>=2
167071 && (pSrc->a[0].fg.jointype & JT_LTORJ)!=0
167072 ){
@@ -167209,10 +167389,11 @@
167389 static const u8 ops[] = {TK_GE, TK_LE};
167390 assert( ExprUseXList(pExpr) );
167391 pList = pExpr->x.pList;
167392 assert( pList!=0 );
167393 assert( pList->nExpr==2 );
167394 assert( pWC->a[idxTerm].nChild==0 );
167395 for(i=0; i<2; i++){
167396 Expr *pNewExpr;
167397 int idxNew;
167398 pNewExpr = sqlite3PExpr(pParse, ops[i],
167399 sqlite3ExprDup(db, pExpr->pLeft, 0),
@@ -167419,12 +167600,15 @@
167600 && (pExpr->x.pSelect->pPrior==0 || (pExpr->x.pSelect->selFlags & SF_Values))
167601 #ifndef SQLITE_OMIT_WINDOWFUNC
167602 && pExpr->x.pSelect->pWin==0
167603 #endif
167604 && pWC->op==TK_AND
167605 && pExpr->x.pSelect->pEList->nExpr <= UMXV(pTerm->nChild)
167606 /* ^-- See bug 2026-06-04T10:00:49Z */
167607 ){
167608 int i;
167609 assert( pTerm->nChild==0 );
167610 for(i=0; i<sqlite3ExprVectorSize(pExpr->pLeft); i++){
167611 int idxNew;
167612 idxNew = whereClauseInsert(pWC, pExpr, TERM_VIRTUAL|TERM_SLICE);
167613 pWC->a[idxNew].u.x.iField = i+1;
167614 exprAnalyze(pSrc, pWC, idxNew);
@@ -186978,11 +187162,11 @@
187162 ** or disables the collection of memory allocation statistics. */
187163 sqlite3GlobalConfig.bMemstat = va_arg(ap, int);
187164 break;
187165 }
187166 case SQLITE_CONFIG_SMALL_MALLOC: {
187167 sqlite3GlobalConfig.bSmallMalloc = va_arg(ap, int)!=0;
187168 break;
187169 }
187170 case SQLITE_CONFIG_PAGECACHE: {
187171 /* EVIDENCE-OF: R-18761-36601 There are three arguments to
187172 ** SQLITE_CONFIG_PAGECACHE: A pointer to 8-byte aligned memory (pMem),
@@ -193287,10 +193471,16 @@
193471 #ifndef SQLITE_CORE
193472 /* # include "sqlite3ext.h" */
193473 SQLITE_EXTENSION_INIT1
193474 #endif
193475
193476
193477 /*
193478 ** Assume any b-tree layer with more levels than this is corrupt.
193479 */
193480 #define FTS3_MAX_BTREE_HEIGHT 48
193481
193482 typedef struct Fts3HashWrapper Fts3HashWrapper;
193483 struct Fts3HashWrapper {
193484 Fts3Hash hash; /* Hash table */
193485 int nRef; /* Number of pointers to this object */
193486 };
@@ -195003,11 +195193,15 @@
195193 int iHeight; /* Height of this node in tree */
195194
195195 assert( piLeaf || piLeaf2 );
195196
195197 fts3GetVarint32(zNode, &iHeight);
195198 if( iHeight>FTS3_MAX_BTREE_HEIGHT ){
195199 rc = FTS_CORRUPT_VTAB;
195200 }else{
195201 rc = fts3ScanInteriorNode(zTerm, nTerm, zNode, nNode, piLeaf, piLeaf2);
195202 }
195203 assert_fts3_nc( !piLeaf2 || !piLeaf || rc!=SQLITE_OK || (*piLeaf<=*piLeaf2) );
195204
195205 if( rc==SQLITE_OK && iHeight>1 ){
195206 char *zBlob = 0; /* Blob read from %_segments table */
195207 int nBlob = 0; /* Size of zBlob in bytes */
@@ -195048,12 +195242,17 @@
195242 char **pp, /* IN/OUT: Output pointer */
195243 sqlite3_int64 *piPrev, /* IN/OUT: Previous value written to list */
195244 sqlite3_int64 iVal /* Write this value to the list */
195245 ){
195246 assert_fts3_nc( iVal-*piPrev > 0 || (*piPrev==0 && iVal==0) );
195247 if( iVal-(*piPrev)>=0 ){
195248 /* Refuse to write a negative delta integer. This only happens with a
195249 ** corrupt db (see the assert above) and can cause buffer overwrites
195250 ** in some cases. */
195251 *pp += sqlite3Fts3PutVarint(*pp, iVal-*piPrev);
195252 *piPrev = iVal;
195253 }
195254 }
195255
195256 /*
195257 ** When this function is called, *ppPoslist is assumed to point to the
195258 ** start of a position-list. After it returns, *ppPoslist points to the
@@ -199532,11 +199731,11 @@
199731 break;
199732
199733 /* State 3. The integer just read is a column number. */
199734 default: assert( eState==3 );
199735 iCol = (int)v;
199736 if( iCol<1 || iCol>(pFts3->nColumn+1) ){
199737 rc = SQLITE_CORRUPT_VTAB;
199738 break;
199739 }
199740 if( fts3auxGrowStatArray(pCsr, iCol+2) ) return SQLITE_NOMEM;
199741 pCsr->aStat[iCol+1].nDoc++;
@@ -206469,10 +206668,14 @@
206668 iMul = -1;
206669 }
206670 for(/* no-op */; zText[i]>='0' && zText[i]<='9'; i++){
206671 iVal = iVal*10 + (zText[i] - '0');
206672 }
206673
206674 /* This if() clause is just to avoid an integer overflow. The record is
206675 ** corrupt in this case. */
206676 if( (i64)iVal==SMALLEST_INT64 ) iMul = 1;
206677 *pnByte = ((i64)iVal * (i64)iMul);
206678 }
206679 }
206680
206681
@@ -209331,14 +209534,13 @@
209534 */
209535
209536 /*
209537 ** Allocate a two-slot MatchinfoBuffer object.
209538 */
209539 static MatchinfoBuffer *fts3MIBufferNew(i64 nElem, const char *zMatchinfo){
209540 MatchinfoBuffer *pRet;
209541 sqlite3_int64 nByte = sizeof(u32) * (2*(i64)nElem+1) + SZ_MATCHINFOBUFFER(1);
 
209542 sqlite3_int64 nStr = strlen(zMatchinfo);
209543
209544 pRet = sqlite3Fts3MallocZero(nByte + nStr+1);
209545 if( pRet ){
209546 pRet->aMI[0] = (u8*)(&pRet->aMI[1]) - (u8*)pRet;
@@ -210205,12 +210407,12 @@
210407 }
210408 sqlite3Fts3ErrMsg(pzErr, "unrecognized matchinfo request: %c", cArg);
210409 return SQLITE_ERROR;
210410 }
210411
210412 static i64 fts3MatchinfoSize(MatchInfo *pInfo, char cArg){
210413 i64 nVal; /* Number of integers output by cArg */
210414
210415 switch( cArg ){
210416 case FTS3_MATCHINFO_NDOC:
210417 case FTS3_MATCHINFO_NPHRASE:
210418 case FTS3_MATCHINFO_NCOL:
@@ -210222,20 +210424,20 @@
210424 case FTS3_MATCHINFO_LCS:
210425 nVal = pInfo->nCol;
210426 break;
210427
210428 case FTS3_MATCHINFO_LHITS:
210429 nVal = (i64)pInfo->nCol * pInfo->nPhrase;
210430 break;
210431
210432 case FTS3_MATCHINFO_LHITS_BM:
210433 nVal = (i64)pInfo->nPhrase * ((pInfo->nCol + 31) / 32);
210434 break;
210435
210436 default:
210437 assert( cArg==FTS3_MATCHINFO_HITS );
210438 nVal = (i64)pInfo->nCol * pInfo->nPhrase * 3;
210439 break;
210440 }
210441
210442 return nVal;
210443 }
@@ -210513,11 +210715,11 @@
210715 }
210716 break;
210717
210718 case FTS3_MATCHINFO_LHITS_BM:
210719 case FTS3_MATCHINFO_LHITS: {
210720 i64 nZero = fts3MatchinfoSize(pInfo, zArg[i]) * sizeof(u32);
210721 memset(pInfo->aMatchinfo, 0, nZero);
210722 rc = fts3ExprLHitGather(pCsr->pExpr, pInfo);
210723 break;
210724 }
210725
@@ -210582,11 +210784,11 @@
210784 ** matchinfo function has been called for this query. In this case
210785 ** allocate the array used to accumulate the matchinfo data and
210786 ** initialize those elements that are constant for every row.
210787 */
210788 if( pCsr->pMIBuffer==0 ){
210789 i64 nMatchinfo = 0; /* Number of u32 elements in match-info */
210790 int i; /* Used to iterate through zArg */
210791
210792 /* Determine the number of phrases in the query */
210793 pCsr->nPhrase = fts3ExprPhraseCount(pCsr->pExpr);
210794 sInfo.nPhrase = pCsr->nPhrase;
@@ -218269,10 +218471,13 @@
218471 ** be because the shadow tables hold erroneous data. */
218472 if( rc==SQLITE_ERROR ){
218473 rc = SQLITE_CORRUPT_VTAB;
218474 RTREE_IS_CORRUPT(pRtree);
218475 }
218476 }else if( iNode<=0 ){
218477 RTREE_IS_CORRUPT(pRtree);
218478 rc = SQLITE_CORRUPT_VTAB;
218479 }else if( pRtree->iNodeSize==sqlite3_blob_bytes(pRtree->pNodeBlob) ){
218480 pNode = (RtreeNode *)sqlite3_malloc64(sizeof(RtreeNode)+pRtree->iNodeSize);
218481 if( !pNode ){
218482 rc = SQLITE_NOMEM;
218483 }else{
@@ -218910,11 +219115,11 @@
219115 i64 iRowid,
219116 int *piIndex
219117 ){
219118 int ii;
219119 int nCell = NCELL(pNode);
219120 assert( nCell<65536 && nCell>=0 );
219121 for(ii=0; ii<nCell; ii++){
219122 if( nodeGetRowid(pRtree, pNode, ii)==iRowid ){
219123 *piIndex = ii;
219124 return SQLITE_OK;
219125 }
@@ -223953,10 +224158,13 @@
224158 if( c>=0xc0 ){ \
224159 c = icuUtf8Trans1[c-0xc0]; \
224160 while( (*zIn & 0xc0)==0x80 ){ \
224161 c = (c<<6) + (0x3f & *(zIn++)); \
224162 } \
224163 if( c<0x80 \
224164 || (c&0xFFFFF800)==0xD800 \
224165 || (c&0xFFFFFFFE)==0xFFFE ){ c = 0xFFFD; } \
224166 }
224167
224168 #define SQLITE_ICU_SKIP_UTF8(zIn) \
224169 assert( *zIn ); \
224170 if( *(zIn++)>=0xc0 ){ \
@@ -225858,20 +226066,30 @@
226066 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
226067 -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
226068 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
226069 -1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
226070 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
226071
226072 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226073 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226074 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226075 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226076 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226077 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226078 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226079 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
226080 };
226081 unsigned int v = 0;
226082 int c;
226083 unsigned char *z = (unsigned char*)*pz;
226084 unsigned char *zEnd = z + (*pLen);
226085 while( z<zEnd && (c = zValue[*z])>=0 ){
226086 v = (v<<6) + c;
226087 z++;
226088 }
226089
226090 *pLen -= (int)(z - (unsigned char*)*pz);
226091 *pz = (char*)z;
226092 return v;
226093 }
226094
226095 #if RBU_ENABLE_DELTA_CKSUM
@@ -225943,23 +226161,24 @@
226161 #if RBU_ENABLE_DELTA_CKSUM
226162 char *zOrigOut = zOut;
226163 #endif
226164
226165 limit = rbuDeltaGetInt(&zDelta, &lenDelta);
226166 if( lenDelta<=0 || *zDelta!='\n' ){
226167 /* ERROR: size integer not terminated by "\n" */
226168 return -1;
226169 }
226170 zDelta++; lenDelta--;
226171 while( *zDelta && lenDelta>0 ){
226172 unsigned int cnt, ofst;
226173 cnt = rbuDeltaGetInt(&zDelta, &lenDelta);
226174 if( lenDelta<=0 ) return -1;
226175 switch( zDelta[0] ){
226176 case '@': {
226177 zDelta++; lenDelta--;
226178 ofst = rbuDeltaGetInt(&zDelta, &lenDelta);
226179 if( lenDelta>0 || zDelta[0]!=',' ){
226180 /* ERROR: copy command not terminated by ',' */
226181 return -1;
226182 }
226183 zDelta++; lenDelta--;
226184 total += cnt;
@@ -225980,11 +226199,11 @@
226199 total += cnt;
226200 if( total>limit ){
226201 /* ERROR: insert command gives an output larger than predicted */
226202 return -1;
226203 }
226204 if( (i64)cnt>(i64)lenDelta ){
226205 /* ERROR: insert count exceeds size of delta */
226206 return -1;
226207 }
226208 memcpy(zOut, zDelta, cnt);
226209 zOut += cnt;
@@ -226018,11 +226237,11 @@
226237 }
226238
226239 static int rbuDeltaOutputSize(const char *zDelta, int lenDelta){
226240 int size;
226241 size = rbuDeltaGetInt(&zDelta, &lenDelta);
226242 if( lenDelta<=0 || *zDelta!='\n' ){
226243 /* ERROR: size integer not terminated by "\n" */
226244 return -1;
226245 }
226246 return size;
226247 }
@@ -226066,11 +226285,11 @@
226285 if( nOut<0 ){
226286 sqlite3_result_error(context, "corrupt fossil delta", -1);
226287 return;
226288 }
226289
226290 aOut = sqlite3_malloc64((i64)nOut+1);
226291 if( aOut==0 ){
226292 sqlite3_result_error_nomem(context);
226293 }else{
226294 nOut2 = rbuDeltaApply(aOrig, nOrig, aDelta, nDelta, aOut);
226295 if( nOut2!=nOut ){
@@ -244051,11 +244270,11 @@
244270 ){
244271 HighlightContext ctx;
244272 int rc = SQLITE_OK; /* Return code */
244273 int iCol; /* 1st argument to snippet() */
244274 const char *zEllips; /* 4th argument to snippet() */
244275 i64 nToken; /* 5th argument to snippet() */
244276 int nInst = 0; /* Number of instance matches this row */
244277 int i; /* Used to iterate through instances */
244278 int nPhrase; /* Number of phrases in query */
244279 unsigned char *aSeen; /* Array of "seen instance" flags */
244280 int iBestCol; /* Column containing best snippet */
@@ -244076,11 +244295,11 @@
244295 iCol = sqlite3_value_int(apVal[0]);
244296 ctx.zOpen = fts5ValueToText(apVal[1]);
244297 ctx.zClose = fts5ValueToText(apVal[2]);
244298 ctx.iRangeEnd = -1;
244299 zEllips = fts5ValueToText(apVal[3]);
244300 nToken = (int)(MIN( MAX(sqlite3_value_int64(apVal[4]), 0), 64));
244301
244302 iBestCol = (iCol>=0 ? iCol : 0);
244303 nPhrase = pApi->xPhraseCount(pFts);
244304 aSeen = sqlite3_malloc64(nPhrase);
244305 if( aSeen==0 ){
@@ -246792,11 +247011,11 @@
247011 /* Add an entry to each output position list */
247012 for(i=0; i<pNear->nPhrase; i++){
247013 i64 iPos = a[i].reader.iPos;
247014 Fts5PoslistWriter *pWriter = &a[i].writer;
247015 if( a[i].pOut->n==0 || iPos!=pWriter->iPrev ){
247016 sqlite3Fts5PoslistSafeAppend(a[i].pOut, &pWriter->iPrev, iPos);
247017 }
247018 }
247019
247020 iAdv = 0;
247021 iMin = a[0].reader.iLookahead;
@@ -247743,14 +247962,14 @@
247962 rc = SQLITE_NOMEM;
247963 }else{
247964 memset(pSyn, 0, (size_t)nByte);
247965 pSyn->pTerm = ((char*)pSyn) + sizeof(Fts5ExprTerm) + sizeof(Fts5Buffer);
247966 pSyn->nFullTerm = pSyn->nQueryTerm = nToken;
247967 memcpy(pSyn->pTerm, pToken, nToken);
247968 if( pCtx->pConfig->bTokendata ){
247969 pSyn->nQueryTerm = (int)strlen(pSyn->pTerm);
247970 }
 
247971 pSyn->pSynonym = pPhrase->aTerm[pPhrase->nTerm-1].pSynonym;
247972 pPhrase->aTerm[pPhrase->nTerm-1].pSynonym = pSyn;
247973 }
247974 }else{
247975 Fts5ExprTerm *pTerm;
@@ -249595,11 +249814,11 @@
249814 ** + 1 byte for a "new column" byte,
249815 ** + 3 bytes for a new column number (16-bit max) as a varint,
249816 ** + 5 bytes for the new position offset (32-bit max).
249817 */
249818 if( (p->nAlloc - p->nData) < (9 + 4 + 1 + 3 + 5) ){
249819 sqlite3_int64 nNew = (i64)p->nAlloc * 2;
249820 Fts5HashEntry *pNew;
249821 Fts5HashEntry **pp;
249822 pNew = (Fts5HashEntry*)sqlite3_realloc64(p, nNew);
249823 if( pNew==0 ) return SQLITE_NOMEM;
249824 pNew->nAlloc = (int)nNew;
@@ -251029,11 +251248,11 @@
251248 }else{
251249 i += fts5GetVarint32(&pData[i], pLvl->nMerge);
251250 i += fts5GetVarint32(&pData[i], nTotal);
251251 if( nTotal<pLvl->nMerge ) rc = FTS5_CORRUPT;
251252 pLvl->aSeg = (Fts5StructureSegment*)sqlite3Fts5MallocZero(&rc,
251253 (i64)nTotal * sizeof(Fts5StructureSegment)
251254 );
251255 nSegment -= nTotal;
251256 }
251257
251258 if( rc==SQLITE_OK ){
@@ -251558,19 +251777,20 @@
251777 assert( pLvl->bEof==0 );
251778 if( iOff<=pLvl->iFirstOff ){
251779 pLvl->bEof = 1;
251780 }else{
251781 u8 *a = pLvl->pData->p;
251782 int nn = pLvl->pData->nn;
251783
251784 pLvl->iOff = 0;
251785 fts5DlidxLvlNext(pLvl);
251786 while( 1 ){
251787 int nZero = 0;
251788 int ii = pLvl->iOff;
251789 u64 delta = 0;
251790
251791 while( ii<nn && a[ii]==0 ){
251792 nZero++;
251793 ii++;
251794 }
251795 ii += sqlite3Fts5GetVarint(&a[ii], &delta);
251796
@@ -251985,11 +252205,11 @@
252205 fts5DataRelease(pIter->pLeaf);
252206 pIter->pLeaf = 0;
252207 while( p->rc==SQLITE_OK && pIter->iLeafPgno>pIter->iTermLeafPgno ){
252208 Fts5Data *pNew;
252209 pIter->iLeafPgno--;
252210 pNew = fts5LeafRead(p, FTS5_SEGMENT_ROWID(
252211 pIter->pSeg->iSegid, pIter->iLeafPgno
252212 ));
252213 if( pNew ){
252214 /* iTermLeafOffset may be equal to szLeaf if the term is the last
252215 ** thing on the page - i.e. the first rowid is on the following page.
@@ -253419,12 +253639,11 @@
253639 }
253640 }
253641
253642 do {
253643 while( i<nChunk && pChunk[i]!=0x01 ){
253644 fts5IndexSkipVarint(pChunk, i);
 
253645 }
253646 if( pCtx->eState ){
253647 fts5BufferSafeAppendBlob(pCtx->pBuf, &pChunk[iStart], i-iStart);
253648 }
253649 if( i<nChunk ){
@@ -255163,10 +255382,15 @@
255382 int iSOP; /* Start-Of-Position-list */
255383 if( pSeg->iLeafPgno==pSeg->iTermLeafPgno ){
255384 iStart = pSeg->iTermLeafOffset;
255385 }else{
255386 iStart = fts5GetU16(&aPg[0]);
255387 }
255388 if( iStart>nPg ){
255389 FTS5_CORRUPT_IDX(p);
255390 sqlite3_free(aIdx);
255391 return;
255392 }
255393
255394 iSOP = iStart + fts5GetVarint(&aPg[iStart], &iDelta);
255395 assert_nc( iSOP<=pSeg->iLeafOffset );
255396
@@ -257849,12 +258073,12 @@
258073 int *pnOut, /* OUT: Number of output pages */
258074 Fts5Data ***papOut /* OUT: Output hash pages */
258075 ){
258076 const int MINSLOT = 32;
258077 int nSlotPerPage = MAX(MINSLOT, (p->pConfig->pgsz - 8) / szKey);
258078 i64 nSlot = 0; /* Number of slots in each output page */
258079 i64 nOut = 0;
258080
258081 /* Figure out how many output pages (nOut) and how many slots per
258082 ** page (nSlot). There are three possibilities:
258083 **
258084 ** 1. The hash table does not yet exist. In this case the new hash
@@ -257875,27 +258099,30 @@
258099 /* Case 1. */
258100 nOut = 1;
258101 nSlot = MINSLOT;
258102 }else if( pSeg->nPgTombstone==1 ){
258103 /* Case 2. */
258104 u32 nElem = fts5GetU32(&pData1->p[4]);
258105 assert( pData1 && iPg1==0 );
258106 if( nElem>((u32)nSlotPerPage/4) ){
258107 nOut = 0;
258108 }else{
258109 nOut = 1;
258110 nSlot = MAX((i64)nElem*4, MINSLOT);
258111 }
258112 }
258113 if( nOut==0 ){
258114 /* Case 3. */
258115 nOut = ((i64)pSeg->nPgTombstone * 2 + 1);
258116 nSlot = nSlotPerPage;
258117 }
258118
258119 /* Allocate the required array and output pages */
258120 while( 1 ){
258121 int res = 0;
258122 i64 ii = 0;
258123 i64 szPage = 0;
258124 Fts5Data **apOut = 0;
258125
258126 /* Allocate space for the new hash table */
258127 assert( nSlot>=MINSLOT );
258128 apOut = (Fts5Data**)sqlite3Fts5MallocZero(&p->rc, sizeof(Fts5Data*) * nOut);
@@ -258429,11 +258656,11 @@
258656 ){
258657
258658 /* Check any rowid-less pages that occur before the current leaf. */
258659 for(iPg=iPrevLeaf+1; iPg<fts5DlidxIterPgno(pDlidx); iPg++){
258660 iKey = FTS5_SEGMENT_ROWID(iSegid, iPg);
258661 pLeaf = fts5LeafRead(p, iKey);
258662 if( pLeaf ){
258663 if( fts5LeafFirstRowidOff(pLeaf)!=0 ) FTS5_CORRUPT_ROWID(p, iKey);
258664 fts5DataRelease(pLeaf);
258665 }
258666 }
@@ -258440,11 +258667,11 @@
258667 iPrevLeaf = fts5DlidxIterPgno(pDlidx);
258668
258669 /* Check that the leaf page indicated by the iterator really does
258670 ** contain the rowid suggested by the same. */
258671 iKey = FTS5_SEGMENT_ROWID(iSegid, iPrevLeaf);
258672 pLeaf = fts5LeafRead(p, iKey);
258673 if( pLeaf ){
258674 i64 iRowid;
258675 int iRowidOff = fts5LeafFirstRowidOff(pLeaf);
258676 ASSERT_SZLEAF_OK(pLeaf);
258677 if( iRowidOff>=pLeaf->szLeaf ){
@@ -263040,11 +263267,11 @@
263267 int nArg, /* Number of args */
263268 sqlite3_value **apUnused /* Function arguments */
263269 ){
263270 assert( nArg==0 );
263271 UNUSED_PARAM2(nArg, apUnused);
263272 sqlite3_result_text(pCtx, "fts5: 2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06", -1, SQLITE_TRANSIENT);
263273 }
263274
263275 /*
263276 ** Implementation of fts5_locale(LOCALE, TEXT) function.
263277 **
263278
+19 -6
--- extsrc/sqlite3.h
+++ extsrc/sqlite3.h
@@ -146,14 +146,14 @@
146146
** [sqlite3_libversion_number()], [sqlite3_sourceid()],
147147
** [sqlite_version()] and [sqlite_source_id()].
148148
*/
149149
#define SQLITE_VERSION "3.54.0"
150150
#define SQLITE_VERSION_NUMBER 3054000
151
-#define SQLITE_SOURCE_ID "2026-05-30 13:23:25 2c605bfb1562d7a3609ad6ffd7446def12f1ac7084e41b9c6723e998c156501d"
151
+#define SQLITE_SOURCE_ID "2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06"
152152
#define SQLITE_SCM_BRANCH "trunk"
153153
#define SQLITE_SCM_TAGS ""
154
-#define SQLITE_SCM_DATETIME "2026-05-30T13:23:25.636Z"
154
+#define SQLITE_SCM_DATETIME "2026-06-16T13:43:08.110Z"
155155
156156
/*
157157
** CAPI3REF: Run-Time Library Version Numbers
158158
** KEYWORDS: sqlite3_version sqlite3_sourceid
159159
**
@@ -4375,11 +4375,12 @@
43754375
** <dd>The maximum number of columns in a table definition or in the
43764376
** result set of a [SELECT] or the maximum number of columns in an index
43774377
** or in an ORDER BY or GROUP BY clause.</dd>)^
43784378
**
43794379
** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4380
-** <dd>The maximum depth of the parse tree on any expression.</dd>)^
4380
+** <dd>The maximum depth of the parse tree on any expression and
4381
+** the maximum nesting depth for subqueries and VIEWs</dd>)^
43814382
**
43824383
** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
43834384
** <dd>The maximum depth of the LALR(1) parser stack used to analyze
43844385
** input SQL statements.</dd>)^
43854386
**
@@ -4406,11 +4407,12 @@
44064407
** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
44074408
** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
44084409
** <dd>The maximum index number of any [parameter] in an SQL statement.)^
44094410
**
44104411
** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4411
-** <dd>The maximum depth of recursion for triggers.</dd>)^
4412
+** <dd>The maximum depth of recursion for triggers, and the maximum
4413
+** nesting depth for separate triggers.</dd>)^
44124414
**
44134415
** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
44144416
** <dd>The maximum number of auxiliary worker threads that a single
44154417
** [prepared statement] may start.</dd>)^
44164418
** </dl>
@@ -11212,12 +11214,23 @@
1121211214
** reopen S as an in-memory database based on the serialization
1121311215
** contained in P. If S is a NULL pointer, the main database is
1121411216
** used. The serialized database P is N bytes in size. M is the size
1121511217
** of the buffer P, which might be larger than N. If M is larger than
1121611218
** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11217
-** SQLite is permitted to add content to the in-memory database as
11218
-** long as the total size does not exceed M bytes.
11219
+** SQLite is permitted to add content to the in-memory database, in
11220
+** page-sized chunks, as long as the total size does not exceed M bytes.
11221
+**
11222
+** The parameter M must be greater than or equal to N. Ideally, M
11223
+** should have a value which is N+(512&times;K)+20 where K determines how
11224
+** must extra space is available to hold new content as the database
11225
+** grows. K can be 0 if the database is read-only.
11226
+**
11227
+** If the database content in P is malformed in a malicious way then
11228
+** it is possible that SQLite might try to read a few more than N bytes
11229
+** from P. If the veracity of the database content P is uncertain,
11230
+** then applications are advised to allocate about 20 extra bytes on
11231
+** the end of the P buffer to avoid a memory error.
1121911232
**
1122011233
** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
1122111234
** invoke sqlite3_free() on the serialization buffer when the database
1122211235
** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
1122311236
** SQLite will try to increase the buffer size using sqlite3_realloc64()
1122411237
--- extsrc/sqlite3.h
+++ extsrc/sqlite3.h
@@ -146,14 +146,14 @@
146 ** [sqlite3_libversion_number()], [sqlite3_sourceid()],
147 ** [sqlite_version()] and [sqlite_source_id()].
148 */
149 #define SQLITE_VERSION "3.54.0"
150 #define SQLITE_VERSION_NUMBER 3054000
151 #define SQLITE_SOURCE_ID "2026-05-30 13:23:25 2c605bfb1562d7a3609ad6ffd7446def12f1ac7084e41b9c6723e998c156501d"
152 #define SQLITE_SCM_BRANCH "trunk"
153 #define SQLITE_SCM_TAGS ""
154 #define SQLITE_SCM_DATETIME "2026-05-30T13:23:25.636Z"
155
156 /*
157 ** CAPI3REF: Run-Time Library Version Numbers
158 ** KEYWORDS: sqlite3_version sqlite3_sourceid
159 **
@@ -4375,11 +4375,12 @@
4375 ** <dd>The maximum number of columns in a table definition or in the
4376 ** result set of a [SELECT] or the maximum number of columns in an index
4377 ** or in an ORDER BY or GROUP BY clause.</dd>)^
4378 **
4379 ** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4380 ** <dd>The maximum depth of the parse tree on any expression.</dd>)^
 
4381 **
4382 ** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
4383 ** <dd>The maximum depth of the LALR(1) parser stack used to analyze
4384 ** input SQL statements.</dd>)^
4385 **
@@ -4406,11 +4407,12 @@
4406 ** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
4407 ** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
4408 ** <dd>The maximum index number of any [parameter] in an SQL statement.)^
4409 **
4410 ** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4411 ** <dd>The maximum depth of recursion for triggers.</dd>)^
 
4412 **
4413 ** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
4414 ** <dd>The maximum number of auxiliary worker threads that a single
4415 ** [prepared statement] may start.</dd>)^
4416 ** </dl>
@@ -11212,12 +11214,23 @@
11212 ** reopen S as an in-memory database based on the serialization
11213 ** contained in P. If S is a NULL pointer, the main database is
11214 ** used. The serialized database P is N bytes in size. M is the size
11215 ** of the buffer P, which might be larger than N. If M is larger than
11216 ** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11217 ** SQLite is permitted to add content to the in-memory database as
11218 ** long as the total size does not exceed M bytes.
 
 
 
 
 
 
 
 
 
 
 
11219 **
11220 ** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
11221 ** invoke sqlite3_free() on the serialization buffer when the database
11222 ** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
11223 ** SQLite will try to increase the buffer size using sqlite3_realloc64()
11224
--- extsrc/sqlite3.h
+++ extsrc/sqlite3.h
@@ -146,14 +146,14 @@
146 ** [sqlite3_libversion_number()], [sqlite3_sourceid()],
147 ** [sqlite_version()] and [sqlite_source_id()].
148 */
149 #define SQLITE_VERSION "3.54.0"
150 #define SQLITE_VERSION_NUMBER 3054000
151 #define SQLITE_SOURCE_ID "2026-06-16 13:43:08 3f3fb9b638f59ad982beafb7c117f24ddd3da612e62c862510805fa672ffae06"
152 #define SQLITE_SCM_BRANCH "trunk"
153 #define SQLITE_SCM_TAGS ""
154 #define SQLITE_SCM_DATETIME "2026-06-16T13:43:08.110Z"
155
156 /*
157 ** CAPI3REF: Run-Time Library Version Numbers
158 ** KEYWORDS: sqlite3_version sqlite3_sourceid
159 **
@@ -4375,11 +4375,12 @@
4375 ** <dd>The maximum number of columns in a table definition or in the
4376 ** result set of a [SELECT] or the maximum number of columns in an index
4377 ** or in an ORDER BY or GROUP BY clause.</dd>)^
4378 **
4379 ** [[SQLITE_LIMIT_EXPR_DEPTH]] ^(<dt>SQLITE_LIMIT_EXPR_DEPTH</dt>
4380 ** <dd>The maximum depth of the parse tree on any expression and
4381 ** the maximum nesting depth for subqueries and VIEWs</dd>)^
4382 **
4383 ** [[SQLITE_LIMIT_PARSER_DEPTH]] ^(<dt>SQLITE_LIMIT_PARSER_DEPTH</dt>
4384 ** <dd>The maximum depth of the LALR(1) parser stack used to analyze
4385 ** input SQL statements.</dd>)^
4386 **
@@ -4406,11 +4407,12 @@
4407 ** [[SQLITE_LIMIT_VARIABLE_NUMBER]]
4408 ** ^(<dt>SQLITE_LIMIT_VARIABLE_NUMBER</dt>
4409 ** <dd>The maximum index number of any [parameter] in an SQL statement.)^
4410 **
4411 ** [[SQLITE_LIMIT_TRIGGER_DEPTH]] ^(<dt>SQLITE_LIMIT_TRIGGER_DEPTH</dt>
4412 ** <dd>The maximum depth of recursion for triggers, and the maximum
4413 ** nesting depth for separate triggers.</dd>)^
4414 **
4415 ** [[SQLITE_LIMIT_WORKER_THREADS]] ^(<dt>SQLITE_LIMIT_WORKER_THREADS</dt>
4416 ** <dd>The maximum number of auxiliary worker threads that a single
4417 ** [prepared statement] may start.</dd>)^
4418 ** </dl>
@@ -11212,12 +11214,23 @@
11214 ** reopen S as an in-memory database based on the serialization
11215 ** contained in P. If S is a NULL pointer, the main database is
11216 ** used. The serialized database P is N bytes in size. M is the size
11217 ** of the buffer P, which might be larger than N. If M is larger than
11218 ** N, and the SQLITE_DESERIALIZE_READONLY bit is not set in F, then
11219 ** SQLite is permitted to add content to the in-memory database, in
11220 ** page-sized chunks, as long as the total size does not exceed M bytes.
11221 **
11222 ** The parameter M must be greater than or equal to N. Ideally, M
11223 ** should have a value which is N+(512&times;K)+20 where K determines how
11224 ** must extra space is available to hold new content as the database
11225 ** grows. K can be 0 if the database is read-only.
11226 **
11227 ** If the database content in P is malformed in a malicious way then
11228 ** it is possible that SQLite might try to read a few more than N bytes
11229 ** from P. If the veracity of the database content P is uncertain,
11230 ** then applications are advised to allocate about 20 extra bytes on
11231 ** the end of the P buffer to avoid a memory error.
11232 **
11233 ** If the SQLITE_DESERIALIZE_FREEONCLOSE bit is set in F, then SQLite will
11234 ** invoke sqlite3_free() on the serialization buffer when the database
11235 ** connection closes. If the SQLITE_DESERIALIZE_RESIZEABLE bit is set, then
11236 ** SQLite will try to increase the buffer size using sqlite3_realloc64()
11237
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154154
button,
155155
select {
156156
text-transform: none
157157
}
158158
button,
159
-html input[type=button],
159
+input[type=button],
160160
input[type=reset],
161
-input[type=submit] {
161
+input[type=submit]{
162162
cursor: pointer
163163
}
164
-button[disabled],
165
-html input[disabled] {
164
+button:disabled,
165
+input:disabled {
166166
cursor: default
167167
}
168
-button::-moz-focus-inner,
169
-input::-moz-focus-inner {
170
- border: 0;
171
- padding: 0
172
-}
173168
input {
174169
line-height: normal
175170
}
176171
input[type=checkbox],
177172
input[type=radio] {
@@ -307,11 +302,11 @@
307302
text-decoration: none;
308303
text-align: center;
309304
white-space: nowrap;
310305
cursor: pointer
311306
}
312
-input[type=submit]:disabled {
307
+input:disabled {
313308
color: rgb(70,70,70);
314309
background-color: rgb(153,153,153);
315310
}
316311
317312
@media (min-width:550px) {
@@ -416,30 +411,20 @@
416411
.offset-by-eleven.columns {
417412
margin-left: 95.33333%
418413
}
419414
}
420415
.button,
421
-button {
422
- color: #aaa;
423
- background-color: #444;
424
- border-radius: 5px;
425
- border: 0
426
-}
416
+button,
427417
input[type=button],
428418
input[type=reset],
429419
input[type=submit] {
430420
color: #ddd;
431421
background-color: #446979;
432422
border: 0;
433423
border-radius: 5px
434424
}
435
-.button:hover,
436
-button:hover {
437
- color: #444;
438
- background-color: #aaa;
439
- outline: 0
440
-}
425
+button:hover,
441426
input[type=button]:hover,
442427
input[type=reset]:hover,
443428
input[type=submit]:hover {
444429
color: #446979;
445430
background-color: #ddd;
@@ -452,29 +437,20 @@
452437
input[type=submit]:focus {
453438
color: #333;
454439
border-color: #888;
455440
outline: 0
456441
}
457
-.button.button-primary,
458
-.button.button-primary:focus,
459
-.button.button-primary:hover,
460
-button.button-primary,
461
-button.button-primary:focus,
462
-button.button-primary:hover,
463
-input[type=button].button-primary,
464
-input[type=button].button-primary:focus,
465
-input[type=button].button-primary:hover,
466
-input[type=reset].button-primary,
467
-input[type=reset].button-primary:focus,
468
-input[type=reset].button-primary:hover,
469
-input[type=submit].button-primary,
470
-input[type=submit].button-primary:focus,
471
-input[type=submit].button-primary:hover {
472
- color: #303536;
473
- background-color: #ff8000;
474
- border-color: #ff8000
475
-}
442
+
443
+button:disabled,
444
+input[type=button]:disabled,
445
+input[type=reset]:disabled,
446
+input[type=submit]:disabled{
447
+ color: #ddd;
448
+ background-color: #7f7f7f;
449
+ opacity: 0.8;
450
+}
451
+
476452
input[type=email],
477453
input[type=number],
478454
input[type=password],
479455
input[type=search],
480456
input[type=tel],
481457
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154 button,
155 select {
156 text-transform: none
157 }
158 button,
159 html input[type=button],
160 input[type=reset],
161 input[type=submit] {
162 cursor: pointer
163 }
164 button[disabled],
165 html input[disabled] {
166 cursor: default
167 }
168 button::-moz-focus-inner,
169 input::-moz-focus-inner {
170 border: 0;
171 padding: 0
172 }
173 input {
174 line-height: normal
175 }
176 input[type=checkbox],
177 input[type=radio] {
@@ -307,11 +302,11 @@
307 text-decoration: none;
308 text-align: center;
309 white-space: nowrap;
310 cursor: pointer
311 }
312 input[type=submit]:disabled {
313 color: rgb(70,70,70);
314 background-color: rgb(153,153,153);
315 }
316
317 @media (min-width:550px) {
@@ -416,30 +411,20 @@
416 .offset-by-eleven.columns {
417 margin-left: 95.33333%
418 }
419 }
420 .button,
421 button {
422 color: #aaa;
423 background-color: #444;
424 border-radius: 5px;
425 border: 0
426 }
427 input[type=button],
428 input[type=reset],
429 input[type=submit] {
430 color: #ddd;
431 background-color: #446979;
432 border: 0;
433 border-radius: 5px
434 }
435 .button:hover,
436 button:hover {
437 color: #444;
438 background-color: #aaa;
439 outline: 0
440 }
441 input[type=button]:hover,
442 input[type=reset]:hover,
443 input[type=submit]:hover {
444 color: #446979;
445 background-color: #ddd;
@@ -452,29 +437,20 @@
452 input[type=submit]:focus {
453 color: #333;
454 border-color: #888;
455 outline: 0
456 }
457 .button.button-primary,
458 .button.button-primary:focus,
459 .button.button-primary:hover,
460 button.button-primary,
461 button.button-primary:focus,
462 button.button-primary:hover,
463 input[type=button].button-primary,
464 input[type=button].button-primary:focus,
465 input[type=button].button-primary:hover,
466 input[type=reset].button-primary,
467 input[type=reset].button-primary:focus,
468 input[type=reset].button-primary:hover,
469 input[type=submit].button-primary,
470 input[type=submit].button-primary:focus,
471 input[type=submit].button-primary:hover {
472 color: #303536;
473 background-color: #ff8000;
474 border-color: #ff8000
475 }
476 input[type=email],
477 input[type=number],
478 input[type=password],
479 input[type=search],
480 input[type=tel],
481
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154 button,
155 select {
156 text-transform: none
157 }
158 button,
159 input[type=button],
160 input[type=reset],
161 input[type=submit]{
162 cursor: pointer
163 }
164 button:disabled,
165 input:disabled {
166 cursor: default
167 }
 
 
 
 
 
168 input {
169 line-height: normal
170 }
171 input[type=checkbox],
172 input[type=radio] {
@@ -307,11 +302,11 @@
302 text-decoration: none;
303 text-align: center;
304 white-space: nowrap;
305 cursor: pointer
306 }
307 input:disabled {
308 color: rgb(70,70,70);
309 background-color: rgb(153,153,153);
310 }
311
312 @media (min-width:550px) {
@@ -416,30 +411,20 @@
411 .offset-by-eleven.columns {
412 margin-left: 95.33333%
413 }
414 }
415 .button,
416 button,
 
 
 
 
 
417 input[type=button],
418 input[type=reset],
419 input[type=submit] {
420 color: #ddd;
421 background-color: #446979;
422 border: 0;
423 border-radius: 5px
424 }
425 button:hover,
 
 
 
 
 
426 input[type=button]:hover,
427 input[type=reset]:hover,
428 input[type=submit]:hover {
429 color: #446979;
430 background-color: #ddd;
@@ -452,29 +437,20 @@
437 input[type=submit]:focus {
438 color: #333;
439 border-color: #888;
440 outline: 0
441 }
442
443 button:disabled,
444 input[type=button]:disabled,
445 input[type=reset]:disabled,
446 input[type=submit]:disabled{
447 color: #ddd;
448 background-color: #7f7f7f;
449 opacity: 0.8;
450 }
451
 
 
 
 
 
 
 
 
 
452 input[type=email],
453 input[type=number],
454 input[type=password],
455 input[type=search],
456 input[type=tel],
457
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
11
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
22
3
-/**
4
- * 1. Set default font family to sans-serif.
5
- * 2. Prevent iOS text size adjust after orientation change, without disabling
6
- * user zoom.
7
- */
8
-
93
html {
104
font-family: sans-serif; /* 1 */
11
- -ms-text-size-adjust: 100%; /* 2 */
12
- -webkit-text-size-adjust: 100%; /* 2 */
135
}
146
157
/**
168
* Remove default margin.
179
*/
@@ -207,11 +199,10 @@
207199
/**
208200
* Address differences between Firefox and other browsers.
209201
*/
210202
211203
hr {
212
- -moz-box-sizing: content-box;
213204
box-sizing: content-box;
214205
height: 0;
215206
}
216207
217208
/**
@@ -277,47 +268,27 @@
277268
button,
278269
select {
279270
text-transform: none;
280271
}
281272
282
-/**
283
- * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
284
- * and `video` controls.
285
- * 2. Correct inability to style clickable `input` types in iOS.
286
- * 3. Improve usability and consistency of cursor style between image-type
287
- * `input` and others.
288
- */
289
-
290273
button,
291
-html input[type="button"], /* 1 */
292
-input[type="reset"],
293
-input[type="submit"],
294
-input[type="button"].submit,
295
-button.submit{
296
- -webkit-appearance: button; /* 2 */
297
- cursor: pointer; /* 3 */
274
+input[type=button],
275
+input[type=reset],
276
+input[type=submit]{
277
+ cursor: pointer;
298278
}
299279
300280
/**
301281
* Re-set default cursor for disabled elements.
302282
*/
303
-
304
-button[disabled],
305
-html input[disabled] {
283
+button:disabled,
284
+input[type=button]:disabled,
285
+input[type=reset]:disabled,
286
+input[type=submit]:disabled{
306287
cursor: default;
307288
}
308289
309
-/**
310
- * Remove inner padding and border in Firefox 4+.
311
- */
312
-
313
-button::-moz-focus-inner,
314
-input::-moz-focus-inner {
315
- border: 0;
316
- padding: 0;
317
-}
318
-
319290
/**
320291
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
321292
* the UA stylesheet.
322293
*/
323294
@@ -348,20 +319,11 @@
348319
input[type="number"]::-webkit-inner-spin-button,
349320
input[type="number"]::-webkit-outer-spin-button {
350321
height: auto;
351322
}
352323
353
-/**
354
- * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
355
- * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
356
- * (include `-moz` to future-proof).
357
- */
358
-
359324
input[type="search"] {
360
- -webkit-appearance: textfield; /* 1 */
361
- -moz-box-sizing: content-box;
362
- -webkit-box-sizing: content-box; /* 2 */
363325
box-sizing: content-box;
364326
}
365327
366328
/**
367329
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
517479
518480
/* Buttons
519481
––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
520482
.button,
521483
button,
522
-input[type="button"],
523
-input[type="reset"],
524
-input[type="submit"],
525
-input[type="button"].submit,
526
-button.submit{
484
+input[type=button],
485
+input[type=reset],
486
+input[type=submit]{
527487
display: inline-block;
528488
height: 3.3rem;
529489
padding: 0 2.2rem;
530
- color: #555 !important;
490
+ color: #444;
491
+ background-color: #f8f8f8;;
492
+ border-color: #446979;
531493
text-align: center;
532494
font-size: 1.1rem;
533495
font-weight: 700;
534496
line-height: 3.3rem;
535497
letter-spacing: .08rem;
536498
text-transform: uppercase;
537499
text-decoration: none;
538500
white-space: nowrap;
539
- background-color: transparent;
540501
border-radius: 4px;
541
- border: 1px solid #ccc;
502
+ /*border: 1px solid #ccc;*/
503
+ border: 1px solid #446979;
542504
cursor: pointer;
543505
box-sizing: border-box;
544506
}
545507
546508
.button:hover,
547509
button:hover,
548
-input[type="button"]:hover,
549
-input[type="reset"]:hover,
550
-.button:focus,
510
+input[type=button]:hover,
511
+input[type=reset]:hover,
512
+input[type=submit]:hover,
513
+.button:hover,
551514
button:focus,
552
-input[type="button"]:focus,
553
-input[type="reset"]:focus {
554
- color: #444 !important;
555
- background-color: #eee;
556
- border-color: #aaa;
515
+input[type=button]:focus,
516
+input[type=reset]:focus,
517
+input[type=submit]:focus{
557518
outline: 0;
558
-}
559
-
560
-input[type="submit"],
561
-input[type="button"].submit,
562
-button.submit{
563
- color: white !important;
564
- background-color: #446979;
565
- border-color: #446979;
566
-}
567
-
568
-input[type="submit"]:hover,
569
-input[type="submit"]:focus,
570
-input[type="button"].submit:hover,
571
-input[type="button"].submit:focus,
572
-button.submit:hover,
573
-button.submit:focus{
574
- color: white !important;
519
+ color: white;
575520
background-color: #648898;
576521
border-color: #648898;
577522
}
578523
579
-input[type="submit"]:disabled,
580
-input[type="button"].submit:disabled,
581
-button.submit:disabled{
582
- color: rgb(128,128,128);
524
+.button:disabled,
525
+button:disabled,
526
+input[type=button]:disabled,
527
+input[type=reset]:disabled,
528
+input[type=submit]:disabled{
529
+ color: #444;
583530
background-color: rgb(153,153,153);
584531
}
532
+
533
+.content a.button,
534
+.submenu a.button,
535
+.submenu a.button:visited {
536
+ color: #444;
537
+ background: #f8f8f8;
538
+}
585539
586540
587541
/* Forms
588542
––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
589543
input[type="email"],
@@ -611,12 +565,10 @@
611565
input[type="text"],
612566
input[type="tel"],
613567
input[type="url"],
614568
input[type="password"],
615569
textarea {
616
- -webkit-appearance: none;
617
- -moz-appearance: none;
618570
appearance: none;
619571
}
620572
621573
textarea {
622574
height: inherit;
@@ -1293,5 +1245,11 @@
12931245
}
12941246
12951247
body.forum .forumPosts.fileage a:visited {
12961248
color: #648999;
12971249
}
1250
+
1251
+/* Chat
1252
+––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
1253
+body.chat .chat-input-field:focus {
1254
+ border-color: #446979;
1255
+}
12981256
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
1 /*! normalize.css v3.0.2 | MIT License | git.io/normalize */
2
3 /**
4 * 1. Set default font family to sans-serif.
5 * 2. Prevent iOS text size adjust after orientation change, without disabling
6 * user zoom.
7 */
8
9 html {
10 font-family: sans-serif; /* 1 */
11 -ms-text-size-adjust: 100%; /* 2 */
12 -webkit-text-size-adjust: 100%; /* 2 */
13 }
14
15 /**
16 * Remove default margin.
17 */
@@ -207,11 +199,10 @@
207 /**
208 * Address differences between Firefox and other browsers.
209 */
210
211 hr {
212 -moz-box-sizing: content-box;
213 box-sizing: content-box;
214 height: 0;
215 }
216
217 /**
@@ -277,47 +268,27 @@
277 button,
278 select {
279 text-transform: none;
280 }
281
282 /**
283 * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
284 * and `video` controls.
285 * 2. Correct inability to style clickable `input` types in iOS.
286 * 3. Improve usability and consistency of cursor style between image-type
287 * `input` and others.
288 */
289
290 button,
291 html input[type="button"], /* 1 */
292 input[type="reset"],
293 input[type="submit"],
294 input[type="button"].submit,
295 button.submit{
296 -webkit-appearance: button; /* 2 */
297 cursor: pointer; /* 3 */
298 }
299
300 /**
301 * Re-set default cursor for disabled elements.
302 */
303
304 button[disabled],
305 html input[disabled] {
 
306 cursor: default;
307 }
308
309 /**
310 * Remove inner padding and border in Firefox 4+.
311 */
312
313 button::-moz-focus-inner,
314 input::-moz-focus-inner {
315 border: 0;
316 padding: 0;
317 }
318
319 /**
320 * Address Firefox 4+ setting `line-height` on `input` using `!important` in
321 * the UA stylesheet.
322 */
323
@@ -348,20 +319,11 @@
348 input[type="number"]::-webkit-inner-spin-button,
349 input[type="number"]::-webkit-outer-spin-button {
350 height: auto;
351 }
352
353 /**
354 * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
355 * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
356 * (include `-moz` to future-proof).
357 */
358
359 input[type="search"] {
360 -webkit-appearance: textfield; /* 1 */
361 -moz-box-sizing: content-box;
362 -webkit-box-sizing: content-box; /* 2 */
363 box-sizing: content-box;
364 }
365
366 /**
367 * Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
517
518 /* Buttons
519 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
520 .button,
521 button,
522 input[type="button"],
523 input[type="reset"],
524 input[type="submit"],
525 input[type="button"].submit,
526 button.submit{
527 display: inline-block;
528 height: 3.3rem;
529 padding: 0 2.2rem;
530 color: #555 !important;
 
 
531 text-align: center;
532 font-size: 1.1rem;
533 font-weight: 700;
534 line-height: 3.3rem;
535 letter-spacing: .08rem;
536 text-transform: uppercase;
537 text-decoration: none;
538 white-space: nowrap;
539 background-color: transparent;
540 border-radius: 4px;
541 border: 1px solid #ccc;
 
542 cursor: pointer;
543 box-sizing: border-box;
544 }
545
546 .button:hover,
547 button:hover,
548 input[type="button"]:hover,
549 input[type="reset"]:hover,
550 .button:focus,
 
551 button:focus,
552 input[type="button"]:focus,
553 input[type="reset"]:focus {
554 color: #444 !important;
555 background-color: #eee;
556 border-color: #aaa;
557 outline: 0;
558 }
559
560 input[type="submit"],
561 input[type="button"].submit,
562 button.submit{
563 color: white !important;
564 background-color: #446979;
565 border-color: #446979;
566 }
567
568 input[type="submit"]:hover,
569 input[type="submit"]:focus,
570 input[type="button"].submit:hover,
571 input[type="button"].submit:focus,
572 button.submit:hover,
573 button.submit:focus{
574 color: white !important;
575 background-color: #648898;
576 border-color: #648898;
577 }
578
579 input[type="submit"]:disabled,
580 input[type="button"].submit:disabled,
581 button.submit:disabled{
582 color: rgb(128,128,128);
 
 
583 background-color: rgb(153,153,153);
584 }
 
 
 
 
 
 
 
585
586
587 /* Forms
588 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
589 input[type="email"],
@@ -611,12 +565,10 @@
611 input[type="text"],
612 input[type="tel"],
613 input[type="url"],
614 input[type="password"],
615 textarea {
616 -webkit-appearance: none;
617 -moz-appearance: none;
618 appearance: none;
619 }
620
621 textarea {
622 height: inherit;
@@ -1293,5 +1245,11 @@
1293 }
1294
1295 body.forum .forumPosts.fileage a:visited {
1296 color: #648999;
1297 }
 
 
 
 
 
 
1298
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
1 /*! normalize.css v3.0.2 | MIT License | git.io/normalize */
2
 
 
 
 
 
 
3 html {
4 font-family: sans-serif; /* 1 */
 
 
5 }
6
7 /**
8 * Remove default margin.
9 */
@@ -207,11 +199,10 @@
199 /**
200 * Address differences between Firefox and other browsers.
201 */
202
203 hr {
 
204 box-sizing: content-box;
205 height: 0;
206 }
207
208 /**
@@ -277,47 +268,27 @@
268 button,
269 select {
270 text-transform: none;
271 }
272
 
 
 
 
 
 
 
 
273 button,
274 input[type=button],
275 input[type=reset],
276 input[type=submit]{
277 cursor: pointer;
 
 
 
278 }
279
280 /**
281 * Re-set default cursor for disabled elements.
282 */
283 button:disabled,
284 input[type=button]:disabled,
285 input[type=reset]:disabled,
286 input[type=submit]:disabled{
287 cursor: default;
288 }
289
 
 
 
 
 
 
 
 
 
 
290 /**
291 * Address Firefox 4+ setting `line-height` on `input` using `!important` in
292 * the UA stylesheet.
293 */
294
@@ -348,20 +319,11 @@
319 input[type="number"]::-webkit-inner-spin-button,
320 input[type="number"]::-webkit-outer-spin-button {
321 height: auto;
322 }
323
 
 
 
 
 
 
324 input[type="search"] {
 
 
 
325 box-sizing: content-box;
326 }
327
328 /**
329 * Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
479
480 /* Buttons
481 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
482 .button,
483 button,
484 input[type=button],
485 input[type=reset],
486 input[type=submit]{
 
 
487 display: inline-block;
488 height: 3.3rem;
489 padding: 0 2.2rem;
490 color: #444;
491 background-color: #f8f8f8;;
492 border-color: #446979;
493 text-align: center;
494 font-size: 1.1rem;
495 font-weight: 700;
496 line-height: 3.3rem;
497 letter-spacing: .08rem;
498 text-transform: uppercase;
499 text-decoration: none;
500 white-space: nowrap;
 
501 border-radius: 4px;
502 /*border: 1px solid #ccc;*/
503 border: 1px solid #446979;
504 cursor: pointer;
505 box-sizing: border-box;
506 }
507
508 .button:hover,
509 button:hover,
510 input[type=button]:hover,
511 input[type=reset]:hover,
512 input[type=submit]:hover,
513 .button:hover,
514 button:focus,
515 input[type=button]:focus,
516 input[type=reset]:focus,
517 input[type=submit]:focus{
 
 
518 outline: 0;
519 color: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520 background-color: #648898;
521 border-color: #648898;
522 }
523
524 .button:disabled,
525 button:disabled,
526 input[type=button]:disabled,
527 input[type=reset]:disabled,
528 input[type=submit]:disabled{
529 color: #444;
530 background-color: rgb(153,153,153);
531 }
532
533 .content a.button,
534 .submenu a.button,
535 .submenu a.button:visited {
536 color: #444;
537 background: #f8f8f8;
538 }
539
540
541 /* Forms
542 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
543 input[type="email"],
@@ -611,12 +565,10 @@
565 input[type="text"],
566 input[type="tel"],
567 input[type="url"],
568 input[type="password"],
569 textarea {
 
 
570 appearance: none;
571 }
572
573 textarea {
574 height: inherit;
@@ -1293,5 +1245,11 @@
1245 }
1246
1247 body.forum .forumPosts.fileage a:visited {
1248 color: #648999;
1249 }
1250
1251 /* Chat
1252 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
1253 body.chat .chat-input-field:focus {
1254 border-color: #446979;
1255 }
1256
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
9898
}
9999
.fileage tr:hover,
100100
div.filetreeline:hover {
101101
background-color: #333;
102102
}
103
-div.file-change-line button {
103
+div.file-change-line button{
104104
background-color: #484848
105105
}
106
+button,
107
+input,
108
+optgroup,
109
+select,
110
+textarea {
111
+ background: inherit;
112
+ color: inherit;
113
+ font: inherit;
114
+ margin: 0
115
+}
106116
.button,
107
-button {
108
- color: #aaa;
109
- background-color: #484848;
110
- border-radius: 5px;
111
- border: 0
112
-}
113
-.button:hover,
114
-button:hover {
115
- background-color: #FF4500f0;
116
- color: rgba(24,24,24,0.8);
117
- outline: 0
118
-}
117
+button,
119118
input[type=button],
120
-input[type=reset],
121
-input[type=submit] {
119
+input[type=submit],
120
+input[type=reset]{
122121
color: #ddd;
123122
background-color: #446979;
123
+ border-radius: 5px;
124124
border: 0;
125
- border-radius: 5px
126125
}
126
+.button:hover,
127
+button:hover,
127128
input[type=button]:hover,
128
-input[type=reset]:hover,
129
-input[type=submit]:hover {
129
+input[type=submit]:hover,
130
+input[type=reset]:hover {
130131
background-color: #FF4500f0;
131132
color: rgba(24,24,24,0.8);
132
- outline: 0
133
+ outline: 0;
133134
}
134
-input[type=submit]:disabled {
135
- color: #363636;
136
- background-color: #707070;
135
+.button:disabled,
136
+button:disabled,
137
+input[type=button]:disabled,
138
+input[type=submit]:disabled,
139
+input[type=reset]:disabled{
140
+ color: #222;
141
+ background-color: #b0b0b0;
142
+ opacity: 0.8;
137143
}
138144
.button:focus,
139145
button:focus,
140146
input[type=button]:focus,
141
-input[type=reset]:focus,
142
-input[type=submit]:focus {
147
+input[type=submit]:focus,
148
+input[type=reset]:focus {
143149
outline: 2px outset #333;
144150
border-color: #888;
145151
}
146152
147153
/* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
529535
}
530536
span.snippet>mark {
531537
color: white;
532538
font-weight: bold;
533539
}
534
-button,
535
-input,
536
-optgroup,
537
-select,
538
-textarea {
539
- background: inherit;
540
- color: inherit;
541
- font: inherit;
542
- margin: 0
543
-}
544
-button {
545
- background-color: rgba(45,45,45,0.75);
546
-}
547540
input, textarea, select {
548541
border: 1px solid rgba(127, 201, 255, 0.9);
549542
padding: 1px;
550543
}
551544
select {
552545
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
98 }
99 .fileage tr:hover,
100 div.filetreeline:hover {
101 background-color: #333;
102 }
103 div.file-change-line button {
104 background-color: #484848
105 }
 
 
 
 
 
 
 
 
 
 
106 .button,
107 button {
108 color: #aaa;
109 background-color: #484848;
110 border-radius: 5px;
111 border: 0
112 }
113 .button:hover,
114 button:hover {
115 background-color: #FF4500f0;
116 color: rgba(24,24,24,0.8);
117 outline: 0
118 }
119 input[type=button],
120 input[type=reset],
121 input[type=submit] {
122 color: #ddd;
123 background-color: #446979;
 
124 border: 0;
125 border-radius: 5px
126 }
 
 
127 input[type=button]:hover,
128 input[type=reset]:hover,
129 input[type=submit]:hover {
130 background-color: #FF4500f0;
131 color: rgba(24,24,24,0.8);
132 outline: 0
133 }
134 input[type=submit]:disabled {
135 color: #363636;
136 background-color: #707070;
 
 
 
 
 
137 }
138 .button:focus,
139 button:focus,
140 input[type=button]:focus,
141 input[type=reset]:focus,
142 input[type=submit]:focus {
143 outline: 2px outset #333;
144 border-color: #888;
145 }
146
147 /* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
529 }
530 span.snippet>mark {
531 color: white;
532 font-weight: bold;
533 }
534 button,
535 input,
536 optgroup,
537 select,
538 textarea {
539 background: inherit;
540 color: inherit;
541 font: inherit;
542 margin: 0
543 }
544 button {
545 background-color: rgba(45,45,45,0.75);
546 }
547 input, textarea, select {
548 border: 1px solid rgba(127, 201, 255, 0.9);
549 padding: 1px;
550 }
551 select {
552
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
98 }
99 .fileage tr:hover,
100 div.filetreeline:hover {
101 background-color: #333;
102 }
103 div.file-change-line button{
104 background-color: #484848
105 }
106 button,
107 input,
108 optgroup,
109 select,
110 textarea {
111 background: inherit;
112 color: inherit;
113 font: inherit;
114 margin: 0
115 }
116 .button,
117 button,
 
 
 
 
 
 
 
 
 
 
 
118 input[type=button],
119 input[type=submit],
120 input[type=reset]{
121 color: #ddd;
122 background-color: #446979;
123 border-radius: 5px;
124 border: 0;
 
125 }
126 .button:hover,
127 button:hover,
128 input[type=button]:hover,
129 input[type=submit]:hover,
130 input[type=reset]:hover {
131 background-color: #FF4500f0;
132 color: rgba(24,24,24,0.8);
133 outline: 0;
134 }
135 .button:disabled,
136 button:disabled,
137 input[type=button]:disabled,
138 input[type=submit]:disabled,
139 input[type=reset]:disabled{
140 color: #222;
141 background-color: #b0b0b0;
142 opacity: 0.8;
143 }
144 .button:focus,
145 button:focus,
146 input[type=button]:focus,
147 input[type=submit]:focus,
148 input[type=reset]:focus {
149 outline: 2px outset #333;
150 border-color: #888;
151 }
152
153 /* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
535 }
536 span.snippet>mark {
537 color: white;
538 font-weight: bold;
539 }
 
 
 
 
 
 
 
 
 
 
 
 
 
540 input, textarea, select {
541 border: 1px solid rgba(127, 201, 255, 0.9);
542 padding: 1px;
543 }
544 select {
545
+72 -10
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191191
** {error: formatted message}
192192
**
193193
** If httpCode<=0 then it defaults to 500.
194194
**
195195
** After calling this, the caller should immediately return.
196
+**
197
+** Returns the resulting http code.
196198
*/
197
-void ajax_route_error(int httpCode, const char * zFmt, ...){
199
+int ajax_route_error(int httpCode, const char * zFmt, ...){
198200
Blob msg = empty_blob;
199201
Blob content = empty_blob;
200202
va_list vargs;
203
+
204
+ if( httpCode<=0 ) httpCode=500;
201205
va_start(vargs,zFmt);
202206
blob_vappendf(&msg, zFmt, vargs);
203207
va_end(vargs);
204208
blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
205209
blob_reset(&msg);
206210
cgi_set_content(&content);
207
- cgi_set_status(httpCode>0 ? httpCode : 500, "Error");
211
+ cgi_set_status(httpCode, "Error");
208212
cgi_set_content_type("application/json");
213
+ return httpCode;
214
+}
215
+
216
+void ajax_route_error_forbidden(){
217
+ ajax_route_error(403, "Permission denied.");
218
+}
219
+
220
+void ajax_route_error_captcha(){
221
+ ajax_route_error(400, "Invalid captcha response.");
222
+}
223
+
224
+void ajax_route_error_csrf(){
225
+ ajax_route_error(403, "Invalid CSRF signature.");
226
+}
227
+
228
+void ajax_route_error_404(const char *zMsg){
229
+ ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
230
+}
231
+
232
+int ajax_check_csrf(int level){
233
+ if( 0==cgi_csrf_safe(level) ){
234
+ ajax_route_error_csrf();
235
+ return 0;
236
+ }
237
+ return 1;
209238
}
210239
211240
/*
212241
** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
213242
** logging in the user.
@@ -224,17 +253,18 @@
224253
int ajax_route_bootstrap(int requireWrite, int requirePost){
225254
login_check_credentials();
226255
if( requireWrite!=0 && g.perm.Write==0 ){
227256
ajax_route_error(403,"Write permissions required.");
228257
return 0;
229
- }else if(0==cgi_csrf_safe(requirePost)){
258
+ }else if(requirePost && 0==cgi_csrf_safe(requirePost)){
230259
ajax_route_error(403,
231260
"CSRF violation (make sure sending of HTTP "
232261
"Referer headers is enabled for XHR "
233262
"connections).");
234263
return 0;
235264
}
265
+ cgi_set_content_type("application/json");
236266
return 1;
237267
}
238268
239269
/*
240270
** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
283313
** AJAX_RENDER_PLAIN_TEXT mode.
284314
**
285315
** iframe_height=integer (default=40) Height, in EMs of HTML preview
286316
** iframe.
287317
**
288
-** User must have Write access to use this page.
289
-**
290318
** Responds with the HTML content of the preview. On error it produces
291319
** a JSON response as documented for ajax_route_error().
292320
**
293321
** Extra response headers:
294322
**
@@ -341,10 +369,37 @@
341369
}
342370
if(zRenderMode!=0){
343371
cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
344372
}
345373
}
374
+
375
+/*
376
+** AJAX route /ajax/artifact.json.
377
+** URL arguments:
378
+**
379
+** uuid=ARTIFACT_ID REQUIRED
380
+**
381
+** and emits either:
382
+**
383
+** { error: "..." }
384
+**
385
+** with a non-200 response code or the artifact's manifest in JSON
386
+** form with a 200 response code.
387
+*/
388
+void ajax_route_artifact_json(void){
389
+ const char *zUuid = P("uuid");
390
+ Blob json = BLOB_INITIALIZER;
391
+ login_check_credentials();
392
+ if( ! g.perm.Read ){
393
+ ajax_route_error_forbidden();
394
+ }else if( artifact_to_json_by_name(zUuid, &json) ){
395
+ @ %b(&json)
396
+ }else{
397
+ ajax_route_error_404("Cannot resolve artifact ID.");
398
+ }
399
+ blob_reset(&json);
400
+}
346401
347402
#if INTERFACE
348403
/*
349404
** Internal mapping of ajax sub-route names to various metadata.
350405
*/
@@ -351,11 +406,12 @@
351406
struct AjaxRoute {
352407
const char *zName; /* Name part of the route after "ajax/" */
353408
void (*xCallback)(); /* Impl function for the route. */
354409
int bWriteMode; /* True if requires write mode */
355410
int bPost; /* True if requires POST (i.e. CSRF
356
- ** verification) */
411
+ ** verification). Value is passed to
412
+ ** cgi_csrf_safe(). */
357413
};
358414
typedef struct AjaxRoute AjaxRoute;
359415
#endif /*INTERFACE*/
360416
361417
/*
@@ -392,20 +448,26 @@
392448
const char * zName = P("name");
393449
AjaxRoute routeName = {0,0,0,0};
394450
const AjaxRoute * pRoute = 0;
395451
const AjaxRoute routes[] = {
396452
/* Keep these sorted by zName (for bsearch()) */
453
+ {"artifact.json", ajax_route_artifact_json, 0, 0},
397454
{"preview-text", ajax_route_preview_text, 0, 1
398
- /* Note that this does not require write permissions in the repo.
399
- ** It should arguably require write permissions but doing means
400
- ** that /chat does not work without check-in permissions:
455
+ /* Preview does not require write permissions in the repo. It
456
+ ** should arguably require write permissions simply to limit abuse
457
+ ** but doing means that /chat does not work without check-in
458
+ ** permissions:
401459
**
402460
** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
403461
**
404462
** This particular route is used by /fileedit and /chat, whereas
405463
** /wikiedit uses a simpler wiki-specific route.
406
- */ }
464
+ */
465
+ /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as
466
+ ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that
467
+ ** the filename is only used for mimetype determination. */
468
+ }
407469
};
408470
409471
if(zName==0 || zName[0]==0){
410472
ajax_route_error(400,"Missing required [route] 'name' parameter.");
411473
return;
412474
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191 ** {error: formatted message}
192 **
193 ** If httpCode<=0 then it defaults to 500.
194 **
195 ** After calling this, the caller should immediately return.
 
 
196 */
197 void ajax_route_error(int httpCode, const char * zFmt, ...){
198 Blob msg = empty_blob;
199 Blob content = empty_blob;
200 va_list vargs;
 
 
201 va_start(vargs,zFmt);
202 blob_vappendf(&msg, zFmt, vargs);
203 va_end(vargs);
204 blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
205 blob_reset(&msg);
206 cgi_set_content(&content);
207 cgi_set_status(httpCode>0 ? httpCode : 500, "Error");
208 cgi_set_content_type("application/json");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209 }
210
211 /*
212 ** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
213 ** logging in the user.
@@ -224,17 +253,18 @@
224 int ajax_route_bootstrap(int requireWrite, int requirePost){
225 login_check_credentials();
226 if( requireWrite!=0 && g.perm.Write==0 ){
227 ajax_route_error(403,"Write permissions required.");
228 return 0;
229 }else if(0==cgi_csrf_safe(requirePost)){
230 ajax_route_error(403,
231 "CSRF violation (make sure sending of HTTP "
232 "Referer headers is enabled for XHR "
233 "connections).");
234 return 0;
235 }
 
236 return 1;
237 }
238
239 /*
240 ** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
283 ** AJAX_RENDER_PLAIN_TEXT mode.
284 **
285 ** iframe_height=integer (default=40) Height, in EMs of HTML preview
286 ** iframe.
287 **
288 ** User must have Write access to use this page.
289 **
290 ** Responds with the HTML content of the preview. On error it produces
291 ** a JSON response as documented for ajax_route_error().
292 **
293 ** Extra response headers:
294 **
@@ -341,10 +369,37 @@
341 }
342 if(zRenderMode!=0){
343 cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
344 }
345 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
347 #if INTERFACE
348 /*
349 ** Internal mapping of ajax sub-route names to various metadata.
350 */
@@ -351,11 +406,12 @@
351 struct AjaxRoute {
352 const char *zName; /* Name part of the route after "ajax/" */
353 void (*xCallback)(); /* Impl function for the route. */
354 int bWriteMode; /* True if requires write mode */
355 int bPost; /* True if requires POST (i.e. CSRF
356 ** verification) */
 
357 };
358 typedef struct AjaxRoute AjaxRoute;
359 #endif /*INTERFACE*/
360
361 /*
@@ -392,20 +448,26 @@
392 const char * zName = P("name");
393 AjaxRoute routeName = {0,0,0,0};
394 const AjaxRoute * pRoute = 0;
395 const AjaxRoute routes[] = {
396 /* Keep these sorted by zName (for bsearch()) */
 
397 {"preview-text", ajax_route_preview_text, 0, 1
398 /* Note that this does not require write permissions in the repo.
399 ** It should arguably require write permissions but doing means
400 ** that /chat does not work without check-in permissions:
 
401 **
402 ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
403 **
404 ** This particular route is used by /fileedit and /chat, whereas
405 ** /wikiedit uses a simpler wiki-specific route.
406 */ }
 
 
 
 
407 };
408
409 if(zName==0 || zName[0]==0){
410 ajax_route_error(400,"Missing required [route] 'name' parameter.");
411 return;
412
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191 ** {error: formatted message}
192 **
193 ** If httpCode<=0 then it defaults to 500.
194 **
195 ** After calling this, the caller should immediately return.
196 **
197 ** Returns the resulting http code.
198 */
199 int ajax_route_error(int httpCode, const char * zFmt, ...){
200 Blob msg = empty_blob;
201 Blob content = empty_blob;
202 va_list vargs;
203
204 if( httpCode<=0 ) httpCode=500;
205 va_start(vargs,zFmt);
206 blob_vappendf(&msg, zFmt, vargs);
207 va_end(vargs);
208 blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
209 blob_reset(&msg);
210 cgi_set_content(&content);
211 cgi_set_status(httpCode, "Error");
212 cgi_set_content_type("application/json");
213 return httpCode;
214 }
215
216 void ajax_route_error_forbidden(){
217 ajax_route_error(403, "Permission denied.");
218 }
219
220 void ajax_route_error_captcha(){
221 ajax_route_error(400, "Invalid captcha response.");
222 }
223
224 void ajax_route_error_csrf(){
225 ajax_route_error(403, "Invalid CSRF signature.");
226 }
227
228 void ajax_route_error_404(const char *zMsg){
229 ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
230 }
231
232 int ajax_check_csrf(int level){
233 if( 0==cgi_csrf_safe(level) ){
234 ajax_route_error_csrf();
235 return 0;
236 }
237 return 1;
238 }
239
240 /*
241 ** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
242 ** logging in the user.
@@ -224,17 +253,18 @@
253 int ajax_route_bootstrap(int requireWrite, int requirePost){
254 login_check_credentials();
255 if( requireWrite!=0 && g.perm.Write==0 ){
256 ajax_route_error(403,"Write permissions required.");
257 return 0;
258 }else if(requirePost && 0==cgi_csrf_safe(requirePost)){
259 ajax_route_error(403,
260 "CSRF violation (make sure sending of HTTP "
261 "Referer headers is enabled for XHR "
262 "connections).");
263 return 0;
264 }
265 cgi_set_content_type("application/json");
266 return 1;
267 }
268
269 /*
270 ** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
313 ** AJAX_RENDER_PLAIN_TEXT mode.
314 **
315 ** iframe_height=integer (default=40) Height, in EMs of HTML preview
316 ** iframe.
317 **
 
 
318 ** Responds with the HTML content of the preview. On error it produces
319 ** a JSON response as documented for ajax_route_error().
320 **
321 ** Extra response headers:
322 **
@@ -341,10 +369,37 @@
369 }
370 if(zRenderMode!=0){
371 cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
372 }
373 }
374
375 /*
376 ** AJAX route /ajax/artifact.json.
377 ** URL arguments:
378 **
379 ** uuid=ARTIFACT_ID REQUIRED
380 **
381 ** and emits either:
382 **
383 ** { error: "..." }
384 **
385 ** with a non-200 response code or the artifact's manifest in JSON
386 ** form with a 200 response code.
387 */
388 void ajax_route_artifact_json(void){
389 const char *zUuid = P("uuid");
390 Blob json = BLOB_INITIALIZER;
391 login_check_credentials();
392 if( ! g.perm.Read ){
393 ajax_route_error_forbidden();
394 }else if( artifact_to_json_by_name(zUuid, &json) ){
395 @ %b(&json)
396 }else{
397 ajax_route_error_404("Cannot resolve artifact ID.");
398 }
399 blob_reset(&json);
400 }
401
402 #if INTERFACE
403 /*
404 ** Internal mapping of ajax sub-route names to various metadata.
405 */
@@ -351,11 +406,12 @@
406 struct AjaxRoute {
407 const char *zName; /* Name part of the route after "ajax/" */
408 void (*xCallback)(); /* Impl function for the route. */
409 int bWriteMode; /* True if requires write mode */
410 int bPost; /* True if requires POST (i.e. CSRF
411 ** verification). Value is passed to
412 ** cgi_csrf_safe(). */
413 };
414 typedef struct AjaxRoute AjaxRoute;
415 #endif /*INTERFACE*/
416
417 /*
@@ -392,20 +448,26 @@
448 const char * zName = P("name");
449 AjaxRoute routeName = {0,0,0,0};
450 const AjaxRoute * pRoute = 0;
451 const AjaxRoute routes[] = {
452 /* Keep these sorted by zName (for bsearch()) */
453 {"artifact.json", ajax_route_artifact_json, 0, 0},
454 {"preview-text", ajax_route_preview_text, 0, 1
455 /* Preview does not require write permissions in the repo. It
456 ** should arguably require write permissions simply to limit abuse
457 ** but doing means that /chat does not work without check-in
458 ** permissions:
459 **
460 ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
461 **
462 ** This particular route is used by /fileedit and /chat, whereas
463 ** /wikiedit uses a simpler wiki-specific route.
464 */
465 /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as
466 ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that
467 ** the filename is only used for mimetype determination. */
468 }
469 };
470
471 if(zName==0 || zName[0]==0){
472 ajax_route_error(400,"Missing required [route] 'name' parameter.");
473 return;
474
+931 -109
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
2222
#include <assert.h>
2323
2424
/*
2525
** Given a presumedly legal attachment target name, this guesses the
2626
** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27
-** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28
-** distinguish the target type.
27
+** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot distinguish
28
+** the target type.
29
+**
30
+** zTarget is an attachment target name: wiki page name, tech-note ID,
31
+** ticket ID, or forumpost hash.
32
+**
33
+** If bFull is true then it requires zTarget to be a full ID for
34
+** tech-notes and tickets, otherwise such IDs may be prefixes. If
35
+** bFull is false then tech-notes and tickets will perform a prefix
36
+** match, but it is up to the caller to provide enough of a prefix to
37
+** rule out ambiguity[^1]. When called repeatedly, this routine can
38
+** run a bit faster and more efficiently if bFull is true, but some
39
+** historical use cases call for prefix matches.
40
+**
41
+** Wiki page names always require an exact match.
42
+**
43
+** Forum posts are a special case:
44
+**
45
+** - They ignore the bFull flag. That is, they will do prefix matches
46
+** but will not match an ambiguous prefix.
47
+**
48
+** - It is up to the caller to, if needed, resolve zTarget using
49
+** forumpost_head_rid2() to resolve the RID of the earliest version
50
+** of the post, as that is the only one which attachments should
51
+** target.
2952
**
30
-** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
31
-** if needed, they resolve zTarget using forumpost_head_rid2() so that
32
-** they get the RID of the earliest version of the post, as that is
33
-** the only one which attachments should target.
53
+** [^1]: Historically (from the perspective of 2026-06) attachment
54
+** target lookups have used GLOB prefix matching but have taken no
55
+** measures to ensure that the prefix is unambiguous. Ergo we do the
56
+** same here. It is assumed that the caller passes enough of a prefix
57
+** to be unambiguous and that's worked out fine so far.
3458
*/
35
-int attachment_target_type(const char *zTarget){
36
- static Stmt q = empty_Stmt_m;
37
- int rc = 0;
38
- if( forumpost_head_rid2(zTarget)>0 ){
59
+int attachment_target_type(const char *zTarget, int bFull){
60
+ if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
61
+ return 0;
62
+ }
63
+ if( symbolic_name_to_rid(zTarget, "f")>0 ){
64
+ /* Check forum posts first because they are the most likely target
65
+ ** as of 2026. We should arguably use something more
66
+ ** specialized/efficient than symbolic_name_to_rid(). */
3967
return CFTYPE_FORUM;
4068
}
41
- if( !q.pStmt ){
42
- db_static_prepare(
43
- &q,
69
+ if( bFull ){
70
+ static Stmt q = empty_Stmt_m;
71
+ int rc = 0;
72
+ if( !q.pStmt ){
73
+ db_static_prepare(
74
+ &q,
75
+ "SELECT CASE "
76
+ /* Ordered by presumed likelihood of attachments. */
77
+ "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
78
+ "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
79
+ "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
80
+ "ELSE 0 END",
81
+ CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
82
+ );
83
+ }
84
+ db_bind_text(&q, ":tgt", zTarget);
85
+ if( SQLITE_ROW==db_step(&q) ){
86
+ rc = db_column_int(&q, 0);
87
+ }
88
+ db_reset(&q);
89
+ return rc;
90
+ }else{
91
+ return db_int(
92
+ 0,
4493
"SELECT CASE "
45
- "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
46
- "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
47
- "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
94
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
95
+ " THEN %d\n"
96
+ "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
97
+ " THEN %d\n"
98
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
99
+ " THEN %d\n"
48100
"ELSE 0 END",
49
- CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
101
+ zTarget, CFTYPE_TICKET,
102
+ zTarget, CFTYPE_WIKI,
103
+ zTarget, CFTYPE_EVENT
50104
);
51105
}
52
- db_bind_text(&q, ":tgt", zTarget);
53
- if( SQLITE_ROW==db_step(&q) ){
54
- rc = db_column_int(&q, 0);
106
+}
107
+
108
+/*
109
+** Given an attachment target name, returns the target's blob.rid.
110
+** zTarget and bFull work as described for attachment_target_type().
111
+**
112
+** For forum posts, this always returns the RID of the first version
113
+** of the post, as attachments should always target that instance.
114
+*/
115
+int attachment_target_rid(const char *zTarget, int bFull){
116
+ int rid = 0;
117
+ const int eType = attachment_target_type(zTarget, bFull);
118
+ switch(eType){
119
+ case CFTYPE_TICKET:
120
+ case CFTYPE_EVENT:{
121
+ const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
122
+ rid = db_int(
123
+ 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
124
+ "WHERE tagname %s '%s-%q%s'\n"
125
+ "AND x.tagtype>0\n"
126
+ "AND x.tagid=t.tagid\n"
127
+ "AND x.rid=b.rid\n"
128
+ "ORDER BY x.mtime DESC",
129
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
130
+ zTagPrefix/*safe-for-%s*/,
131
+ zTarget,
132
+ bFull ? "" : "*"/*safe-for-%s*/
133
+ );
134
+ break;
135
+ }
136
+ case CFTYPE_FORUM:
137
+ rid = db_int(
138
+ 0, "SELECT f.fpid FROM forumpost f, blob b\n"
139
+ "WHERE f.fpid=b.rid\n"
140
+ "AND b.uuid %s '%q%s'",
141
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
142
+ zTarget,
143
+ bFull ? "" : "*"/*safe-for-%s*/
144
+ );
145
+ if( rid>0 ){
146
+ rid = forumpost_head_rid(rid);
147
+ }
148
+ break;
149
+ case CFTYPE_WIKI:
150
+ rid = db_int(
151
+ 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
152
+ "WHERE tagname='wiki-%q'\n"
153
+ "AND x.tagtype>0\n"
154
+ "AND x.tagid=t.tagid\n"
155
+ "AND x.rid=b.rid\n"
156
+ "ORDER BY x.mtime DESC",
157
+ zTarget
158
+ );
159
+ break;
160
+ default:
161
+ break;
162
+ }
163
+ return rid;
164
+}
165
+
166
+/*
167
+** For a given aritfact ID and type (from the CFTYPE_xyz enum),
168
+** returns true if the current user could hypothetically apply and
169
+** attachment to it, else returns 0.
170
+**
171
+** The rid is currently only relevant when eArtifactType is
172
+** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
173
+** not the head RID, to keep non-admins from attaching files to
174
+** threads which have since been taken over by another user (this
175
+** happens when an admin edits another user's post).
176
+*/
177
+int attach_user_may(int rid, int eArtifactType){
178
+ if( g.perm.Admin ) return 1;
179
+ if( !login_is_individual() ) return 0;
180
+ switch(eArtifactType){
181
+ case CFTYPE_FORUM:
182
+ return g.perm.AttachForum && forumpost_is_owner(rid, 0);
183
+ case CFTYPE_WIKI:
184
+ return g.perm.ApndWiki && g.perm.Attach;
185
+ case CFTYPE_TICKET:
186
+ return g.perm.ApndTkt && g.perm.Attach;
187
+ case CFTYPE_EVENT:
188
+ return g.perm.Write && g.perm.ApndWiki && g.perm.Attach;
189
+ default:
190
+ return 0;
55191
}
56
- db_reset(&q);
57
- return rc;
192
+}
193
+
194
+/*
195
+** Emits a single-button FORM which invokes
196
+** /attachadd with target=$zTarget.
197
+*/
198
+void attach_render_attachadd_button(const char *zTarget){
199
+ /* This could be changed from POST to GET, and arguably should so
200
+ ** that the target=X part becomes part of the resulting URL. */
201
+ @ <form method="post" action="%R/attachadd">\
202
+ @ <input type="hidden" name="target" value="%T(zTarget)">\
203
+ @ <input type="submit" value="Attach...">
204
+ @ </form>\
58205
}
59206
60207
/*
61208
** WEBPAGE: attachlist
62209
** List attachments.
@@ -69,20 +216,21 @@
69216
** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
70217
**
71218
** If none are given, all attachments are listed. If one is given, only
72219
** attachments for the designated technote, ticket or wiki page are shown.
73220
**
74
-** HASH may be just a prefix of the relevant technical note or ticket
75
-** artifact hash, in which case all attachments of all technical notes or
76
-** tickets with the prefix will be listed. Forum posts, on the other hand,
77
-** require a unique hash prefix.
221
+** HASH may be just a prefix of the relevant forum post, technical
222
+** note, or ticket artifact hash, in which case all attachments of all
223
+** technical notes or tickets with the prefix will be listed. Forum
224
+** posts, on the other hand, require a unique hash or hash prefix.
78225
*/
79226
void attachlist_page(void){
80227
const char *zPage = P("page");
81228
const char *zTkt = P("tkt");
82229
const char *zTechNote = P("technote");
83230
const char *zForumPost = P("forumpost");
231
+ char *zLink = 0;
84232
Blob sql;
85233
Stmt q;
86234
87235
if( zPage && zTkt ) zTkt = 0;
88236
login_check_credentials();
@@ -102,32 +250,47 @@
102250
if( fnid<=0 ){
103251
webpage_error("Invalid forum post ID: %h", zForumPost);
104252
}
105253
blob_append_sql(&sql, " WHERE target="
106254
"(SELECT uuid FROM blob WHERE rid=%d)", fnid);
255
+ zLink = mprintf("forum post <a href='%R/forumpost/%t'>%#h</a>",
256
+ zForumPost, hash_digits(0), zForumPost);
107257
}else if( zPage ){
108258
if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
109259
style_header("Attachments To Wiki page %h", zPage);
110260
blob_append_sql(&sql, " WHERE target=%Q", zPage);
261
+ zLink = mprintf("wiki page <a href='%R/wiki?name=%t'>%h</a>",
262
+ zPage, zPage);
111263
}else if( zTkt ){
112264
if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
113265
style_header("Attachments To Ticket %S", zTkt);
114266
blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
267
+ zLink = mprintf("ticket <a href='%R/tktview?name=%t'>%#h</a>",
268
+ zTkt, hash_digits(0), zTkt);
115269
}else if( zTechNote ){
116270
if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
117271
style_header("Attachments To Tech Note %S", zTechNote);
118272
blob_append_sql(&sql, " WHERE target GLOB '%q*'",
119273
zTechNote);
274
+ zLink = mprintf("tech-note <a href='%R/technote?name=%t'>%#h</a>",
275
+ zTechNote, hash_digits(0), zTechNote);
120276
}else{
121277
if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
122278
login_needed(g.anon.RdTkt || g.anon.RdWiki);
123279
return;
124280
}
125281
style_header("All Attachments");
126282
}
127283
blob_append_sql(&sql, " ORDER BY mtime DESC");
128284
db_prepare(&q, "%s", blob_sql_text(&sql));
285
+
286
+ if( zLink ){
287
+ @ <h2>Attachments for %s(zLink)</h2>
288
+ fossil_free(zLink);
289
+ zLink = 0;
290
+ }
291
+
129292
@ <ol>
130293
while( db_step(&q)==SQLITE_ROW ){
131294
const char *zDate;
132295
const char *zSrc;
133296
const char *zTarget;
@@ -137,10 +300,11 @@
137300
const char *zUuid;
138301
const char *zDispUser;
139302
const int attachid = db_column_int(&q, 7);
140303
int type;
141304
int i;
305
+ int bDeleted;
142306
char *zUrlTail = 0;
143307
144308
if( moderation_pending(attachid)
145309
&& !moderation_user_could(attachid, 1, 0) ){
146310
/* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
160324
if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
161325
zFilename = &zFilename[i+1];
162326
i = -1;
163327
}
164328
}
165
- type = attachment_target_type(zTarget);
329
+ bDeleted = 0==zSrc || 0==zSrc[0];
330
+ type = attachment_target_type(zTarget, 1);
166331
switch( type ){
167332
case CFTYPE_TICKET:
168333
zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
169334
break;
170335
case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
176341
case CFTYPE_WIKI:
177342
zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
178343
break;
179344
}
180345
@ <li><p>
181
- @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>
346
+ if( bDeleted ){
347
+ @ <s>\
348
+ }
349
+ @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>\
182350
moderation_pending_www(attachid);
183
- @ <br><a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
184
- @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]<br>
351
+ @ <br>\
352
+ @ <a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
353
+ @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]\
354
+ if( bDeleted ){
355
+ @ </s>
356
+ }
357
+ @ <br>
185358
if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
186359
if( zComment && zComment[0] ){
187
- @ %!W(zComment)<br>
360
+ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). %W
361
+ ** (historically used here) assumes fossil-wiki and the
362
+ ** fileformat.wiki doc has always claimed that it defaults to
363
+ ** text/plain. /ainfo assumes it is plain text. */
364
+ @ %h(zComment)<br>
188365
}
189366
if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
190
- if( zSrc==0 || zSrc[0]==0 ){
367
+ if( bDeleted ){
191368
zSrc = "Deleted from";
192369
}else {
193370
zSrc = "Added to";
194371
}
195372
switch( type ){
@@ -362,41 +539,81 @@
362539
Manifest *pManifest;
363540
364541
db_begin_transaction();
365542
blob_init(&content, aContent, szContent);
366543
pManifest = manifest_parse(&content, 0, 0);
544
+ addCompress = pManifest!=0;
367545
manifest_destroy(pManifest);
368546
blob_init(&content, aContent, szContent);
369
- if( pManifest ){
547
+ if( addCompress ){
370548
blob_compress(&content, &content);
371
- addCompress = 1;
372549
}
373550
rid = content_put_ex(&content, 0, 0, 0, needModerator);
374
- zUUID = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
551
+ zUUID = rid_to_uuid(rid);
375552
blob_zero(&manifest);
376553
for(i=n=0; zName[i]; i++){
377554
if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
378555
}
379556
zName += n;
380557
if( zName[0]==0 ) zName = "unknown";
381558
blob_appendf(&manifest, "A %F%s %F %s\n",
382559
zName, addCompress ? ".gz" : "", zTarget, zUUID);
383
- while( fossil_isspace(zComment[0]) ) zComment++;
384
- n = strlen(zComment);
385
- while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
386
- if( n>0 ){
387
- blob_appendf(&manifest, "C %#F\n", n, zComment);
560
+ if( zComment!=0 && zComment[0]!=0 ){
561
+ while( fossil_isspace(zComment[0]) ) zComment++;
562
+ n = strlen(zComment);
563
+ while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
564
+ if( n>0 ){
565
+ blob_appendf(&manifest, "C %#F\n", n, zComment);
566
+ }
388567
}
389568
zDate = date_in_standard_format("now");
390
- blob_appendf(&manifest, "D %s\n", zDate);
569
+ blob_appendf(&manifest, "D %z\n", zDate);
391570
blob_appendf(&manifest, "U %F\n", login_name());
392571
md5sum_blob(&manifest, &cksum);
393572
blob_appendf(&manifest, "Z %b\n", &cksum);
394573
attach_put(&manifest, rid, needModerator);
395574
assert( blob_is_reset(&manifest) );
396575
db_end_transaction(0);
397576
}
577
+
578
+/*
579
+** Renders the "legacy" (static) /attachadd form. One of the first
580
+** four arguments must be non-NULL and the other three must be NULL.
581
+** zComment may be NULL, as may zFrom. See the call sites for more
582
+** context.
583
+*/
584
+static void attach_render_legacy_form(const char *zForumPost,
585
+ const char *zTechNote,
586
+ const char *zTicket,
587
+ const char *zWikiPage,
588
+ const char *zComment,
589
+ const char *zFrom){
590
+ form_begin("enctype='multipart/form-data' id='attachadd-legacy-form'",
591
+ "%R/attachadd");
592
+ @ <div>\
593
+ @ File to Attach:
594
+ @ <input type="file" name="f" size="60"><br>
595
+ @ Description:<br>
596
+ @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
597
+ @ >%h(zComment)</textarea><br>
598
+ if( zForumPost ){
599
+ @ <input type="hidden" name="forumpost" value="%h(zForumPost)">\
600
+ }else if( zTicket ){
601
+ @ <input type="hidden" name="tkt" value="%h(zTicket)">\
602
+ }else if( zTechNote ){
603
+ @ <input type="hidden" name="technote" value="%h(zTechNote)">\
604
+ }else if( zWikiPage ){
605
+ @ <input type="hidden" name="page" value="%h(zWikiPage)">\
606
+ }
607
+ @ <input type="hidden" name="from" value="%h(zFrom)">\
608
+ @ <input type="submit" name="ok" value="Add Attachment">\
609
+ @ <input type="submit" name="cancel" value="Cancel">\
610
+ @ </div>
611
+ captcha_generate(0);
612
+ login_insert_csrf_secret();
613
+ @ </form>
614
+}
398615
399616
/*
400617
** WEBPAGE: attachadd
401618
** Add a new attachment.
402619
**
@@ -404,33 +621,54 @@
404621
** page=WIKIPAGE
405622
** technote=HASH
406623
** forumpost=HASH
407624
** from=URL
408625
**
626
+** Adds a POSTed file attachment to the given target.
627
+**
628
+** Or the "version 2" interface:
629
+**
630
+** target=ATTACHMENT_TARGET
631
+**
632
+** Behaves as documented for attachaddV2_page().
409633
*/
410634
void attachadd_page(void){
411
- const char *zPage = P("page");
412
- const char *zForumPost = P("forumpost");
413
- const char *zTkt = P("tkt");
414
- const char *zTechNote = P("technote");
415
- const char *zFrom = P("from");
416
- const char *aContent = P("f");
417
- const char *zName = PD("f:filename","unknown");
418
- const char *zComment = PD("comment", "");
635
+ const char *zPage;
636
+ const char *zForumPost;
637
+ const char *zTkt;
638
+ const char *zTechNote;
639
+ const char *aContent;
640
+ const char *zName;
641
+ const char *zComment;
419642
const char *zTarget;
420
- char * zTo = 0;
643
+ const char *zFrom; /* Origin page - redirect here after saving */
644
+ char *zTo = 0; /* Optionally redirect here after saving */
421645
char *zTargetType = 0;
422646
char *zExtraFree = 0;
423
- int szContent = atoi(PD("f:bytes","0"));
647
+ int szContent;
424648
int goodCaptcha = 1;
425649
int szLimit = 0;
426650
651
+ if( P("target")!=0 ){
652
+ attachaddV2_page();
653
+ return;
654
+ }
655
+ zPage = P("page");
656
+ zForumPost = P("forumpost");
657
+ zTkt = P("tkt");
658
+ zTechNote = P("technote");
659
+ zFrom = P("from");
660
+ aContent = P("f");
661
+ zName = PD("f:filename","unknown");
662
+ zComment = PD("comment", "");
663
+ szContent = atoi(PD("f:bytes","0"));
664
+
427665
if( zFrom==0 ) zFrom = mprintf("%R/home");
428666
if( P("cancel") ) cgi_redirect(zFrom);
429667
if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
430668
webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
431
- " or technote=X");
669
+ " technote=X, or target=X");
432670
}
433671
login_check_credentials();
434672
if( zForumPost ){
435673
int fpid;
436674
if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
445683
"forum posts.");
446684
}
447685
zTarget = zExtraFree = rid_to_uuid(fpid);
448686
zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
449687
zTarget, zForumPost);
450
- zTo = 1
451
- ? mprintf("%R/forumpost/%S", zTarget)
452
- : mprintf("%R/attachview?forumpost=%T&file=%T",
453
- zTarget, zName)
454
- /* Or we could return directly to the forum post. */;
688
+ zTo = zFrom ? 0 : mprintf("%R/forumpost/%S", zTarget);
455689
}else if( zPage ){
456690
if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
457691
login_needed(g.anon.ApndWiki && g.anon.Attach);
458692
return;
459693
}
460694
if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
461695
fossil_redirect_home();
462696
}
463697
zTarget = zPage;
464
- zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%h\">%h</a>",
698
+ zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%t\">%h</a>",
465699
zPage, zPage);
700
+ zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
466701
}else if ( zTechNote ){
467702
if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
468703
login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
469704
return;
470705
}
@@ -474,11 +709,11 @@
474709
if( zTechNote==0) fossil_redirect_home();
475710
}
476711
zTarget = zTechNote;
477712
zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
478713
zTechNote, zTechNote);
479
-
714
+ zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget);
480715
}else{
481716
assert( zTkt );
482717
if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
483718
login_needed(g.anon.ApndTkt && g.anon.Attach);
484719
return;
@@ -487,21 +722,25 @@
487722
zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
488723
" WHERE tagname GLOB 'tkt-%q*'", zTkt);
489724
if( zTkt==0 ) fossil_redirect_home();
490725
}
491726
zTarget = zTkt;
492
- zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
727
+ zTargetType = mprintf("Ticket <a href=\"%R/tktview/%S\">%S</a>",
493728
zTkt, zTkt);
729
+ zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget);
494730
}
495731
szLimit = db_get_int("attachment-size-limit", 0);
496732
if( szContent<0 || (szLimit && szContent>szLimit) ){
497733
/* This check must be done late so that zTargetType is set up. */
498734
@ <p class="generalError">Attachment %h(zName) is too large.
499735
@ <a href="%R/help/attachment-size-limit">Limit</a> is
500736
@ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
501737
/* Fall through and render form. */
502
- }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
738
+ }else if( P("ok")
739
+ && cgi_csrf_safe(2)
740
+ && szContent>0
741
+ && (goodCaptcha = captcha_is_correct(0)) ){
503742
int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
504743
(zTkt!=0 && ticket_need_moderation(0)) ||
505744
(zPage!=0 && wiki_need_moderation(0));
506745
attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
507746
cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
511750
style_header("Add Attachment");
512751
if( !goodCaptcha ){
513752
@ <p class="generalError">Error: Incorrect security code.</p>
514753
}
515754
@ <h2>Add Attachment To %s(zTargetType)</h2>
516
- form_begin("enctype='multipart/form-data'", "%R/attachadd");
517
- @ <div>
518
- @ File to Attach:
519
- @ <input type="file" name="f" size="60"><br>
520
- @ Description:<br>
521
- @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
522
- @ >%h(zComment)</textarea><br>
523
- if( zForumPost ){
524
- @ <input type="hidden" name="forumpost" value="%h(zTarget)">
525
- }else if( zTkt ){
526
- @ <input type="hidden" name="tkt" value="%h(zTkt)">
527
- }else if( zTechNote ){
528
- @ <input type="hidden" name="technote" value="%h(zTechNote)">
755
+ attach_render_legacy_form(zForumPost, zTechNote, zTkt, zPage,
756
+ zComment, zFrom);
757
+ builtin_fossil_js_bundle_or("attach", NULL);
758
+ style_finish_page();
759
+ fossil_free(zTargetType);
760
+ fossil_free(zExtraFree);
761
+}
762
+
763
+/*
764
+** WEBPAGE: attachadd_ajax_post hidden
765
+**
766
+** Used by attachadd V2 to handle attachments via POST requests with:
767
+**
768
+** target=ATTACHMENT_TARGET
769
+** file1..fileN=FILE_OBJECTS
770
+** dryrun=0|1
771
+**
772
+** Each posted file in the set file1..fileN gets attached to the given
773
+** target, permissions permitting. If dryrun>0 then the change is
774
+** rolled back instead of committed. target=X must refer to a full
775
+** target ID, not a prefix.
776
+**
777
+** Responds with JSON: an empty object on success and
778
+** {error:"message"} on error. The on-success response structure is
779
+** subject to amendment.
780
+*/
781
+void attachadd_ajax_post(void){
782
+ const char *zTarget;
783
+ char *zExtraFree = 0;
784
+ int eTgtType = 0;
785
+ int bNeedsModeration = 0;
786
+ int goodCaptcha = 1;
787
+ int bRollback = 0; /* Roll back if true. */
788
+
789
+ if( ! ajax_route_bootstrap(0, 1) ){
790
+ return;
791
+ }else if( !(goodCaptcha = captcha_is_correct(0)) ){
792
+ goto ajax_err_403;
793
+ }else if( !ajax_check_csrf(2) ){
794
+ return;
795
+ }
796
+ db_begin_transaction();
797
+ zTarget = P("target");
798
+ eTgtType = attachment_target_type(zTarget, 1);
799
+ CX("{");
800
+ switch( eTgtType ){
801
+ default:
802
+ case 0:
803
+ ajax_route_error(400, "Invalid attachment target.");
804
+ db_rollback_transaction();
805
+ return;
806
+ case CFTYPE_FORUM:{
807
+ int fpid;
808
+ if( g.perm.AttachForum==0 ){
809
+ goto ajax_err_403;
810
+ }
811
+ fpid = forumpost_head_rid2(zTarget);
812
+ if( fpid<=0 ){
813
+ goto ajax_err_404;
814
+ }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
815
+ ajax_route_error(403, "Only admins can attach files to "
816
+ "other users' forum posts.");
817
+ db_rollback_transaction();
818
+ return;
819
+ }
820
+ zTarget = zExtraFree = rid_to_uuid(fpid);
821
+ bNeedsModeration = forum_need_moderation();
822
+ break;
823
+ }
824
+ case CFTYPE_EVENT:{
825
+ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
826
+ goto ajax_err_403;
827
+ }
828
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
829
+ zTarget) ){
830
+ zTarget = zExtraFree =
831
+ db_text(0, "SELECT substr(tagname,7) FROM tag"
832
+ " WHERE tagname GLOB 'event-%q*'", zTarget);
833
+ if( zTarget==0){
834
+ goto ajax_err_404;
835
+ }
836
+ }
837
+ bNeedsModeration = 0;
838
+ break;
839
+ }
840
+ case CFTYPE_TICKET:{
841
+ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
842
+ goto ajax_err_403;
843
+ }
844
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
845
+ zTarget) ){
846
+ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
847
+ " WHERE tagname GLOB 'tkt-%q*'", zTarget);
848
+ if( zTarget==0 ){
849
+ goto ajax_err_404;
850
+ }
851
+ }
852
+ bNeedsModeration = ticket_need_moderation(0);
853
+ break;
854
+ }
855
+ case CFTYPE_WIKI:{
856
+ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
857
+ goto ajax_err_403;
858
+ }
859
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
860
+ zTarget) ){
861
+ goto ajax_err_404;
862
+ }
863
+ bNeedsModeration = wiki_need_moderation(0);
864
+ break;
865
+ }
866
+ }
867
+
868
+ if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
869
+ CX("}");
870
+ if( atoi(PD("dryrun","0"))>0 ){
871
+ bRollback = 1;
872
+ }
873
+ }/*else error response was set up*/
874
+ fossil_free(zExtraFree);
875
+ db_end_transaction(bRollback);
876
+ return;
877
+ajax_err_403:
878
+ if( db_transaction_nesting_depth()>0 ){
879
+ db_rollback_transaction();
880
+ }
881
+ ajax_route_error_forbidden();
882
+ return;
883
+ajax_err_404:
884
+ assert( db_transaction_nesting_depth()>0 );
885
+ db_rollback_transaction();
886
+ ajax_route_error(404, "Target not found.");
887
+ return;
888
+}
889
+
890
+/*
891
+** A helper for AJAX-style routines which accept file attachments via
892
+** POST. zTarget must be a full attachment target. bNeedsModeration
893
+** must be true if the attachment requires moderation.
894
+**
895
+** It is up to the caller to have validated all security measures
896
+** before calling this.
897
+**
898
+** This looks for POSTed files names "file1".."fileN", stopping when
899
+** it finds no entry. Returns the number of entries attached to the
900
+** target or a negative value on error (in which case the current db
901
+** transaction will be in a rollback state).
902
+**
903
+** The only errors are currently attachment size limit violations:
904
+** attachments must have a non-0 size and if the attachment-size-limit
905
+** setting is >0 then each file's size must be <= that.
906
+**
907
+** If this returns a negative value, it will have populated an error
908
+** response using ajax_route_error(). On success it produces no
909
+** output.
910
+**
911
+** ACHTUNG: if zTarget is a forum post, it "really should" be the ID
912
+** of the first version of that post, as that's where attachments are
913
+** intended to be applied so that they can be found and removed
914
+** consistently. Potential TODO is have this function do that if
915
+** attachment_target_type(zTarget,1)!=0 but it would (for current
916
+** uses) require duplicating work already done in the callers.
917
+*/
918
+int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
919
+ int i;
920
+ int rc = 0;
921
+ int n = 0;
922
+ int szLimit; /* attachment-max-size setting */
923
+ char aKeyPrefix[20]; /* Buffer for key "file%d" */
924
+ char aKeySize[30]; /* Buffer for key "file%d:bytes" */
925
+ char aKeyName[30]; /* Buffer for key "file%d:filename" */
926
+ char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
927
+
928
+ db_begin_transaction();
929
+ szLimit = db_get_int("attachment-size-limit", 0);
930
+
931
+ for(i = 1; ; ++i, ++n){
932
+ /* Look for P("fileN"), where N=1..n */
933
+ const char *zContent;
934
+ const char *zFilename;
935
+ int szContent;
936
+ sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
937
+ zContent = P(aKeyPrefix);
938
+ if( !zContent ){
939
+ /* End of the list. */
940
+ break;
941
+ }
942
+ sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
943
+ aKeyPrefix);
944
+ szContent = atoi(PD(aKeySize,"-1"));
945
+ if( szContent<=0 ){
946
+ rc = -ajax_route_error(400,"Invalid file size: %d", szContent);
947
+ break;
948
+ }else if( szLimit>0 && szContent>szLimit ){
949
+ rc = -ajax_route_error(413, "File size limit is %d bytes.", szLimit);
950
+ break;
951
+ }else{
952
+ sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
953
+ aKeyPrefix);
954
+ sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
955
+ aKeyPrefix);
956
+ if( 0==(zFilename=P(aKeyName)) ){
957
+ rc = -ajax_route_error(400, "Missing filename.");
958
+ break;
959
+ }
960
+ attach_commit(zFilename, zTarget, zContent, szContent,
961
+ bNeedsModeration, P(aKeyDesc));
962
+ }
963
+ }
964
+ if( rc<0 ){
965
+ db_rollback_transaction();
966
+ return rc;
529967
}else{
530
- @ <input type="hidden" name="page" value="%h(zPage)">
968
+ db_commit_transaction();
969
+ return n;
970
+ }
971
+}
972
+
973
+/*
974
+** Proxy for /attachadd?target=X
975
+**
976
+** Lists attachments for, and can add them to, a target artifact.
977
+**
978
+** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
979
+** from=ORIGINATING_URL
980
+**
981
+** Works like /attachadd but uses a JS-based interactive attachment
982
+** selector.
983
+**
984
+** from=X tells it where to redirect to when it's done.
985
+**
986
+** This page requires a post-2018-ish JS-capable browser.
987
+*/
988
+void attachaddV2_page(void){
989
+ const char *zFrom = P("from");
990
+ const char *zTarget = P("target");
991
+ char *zTo = 0;
992
+ char *zTargetType = 0;
993
+ char *zExtraFree = 0;
994
+ int eTgtType = 0;
995
+ int goodCaptcha = 1;
996
+ char const * noJsArgs[] = {0,0,0,0}; /* Args for noscript form */
997
+
998
+ if( P("cancel") ) cgi_redirect(zFrom);
999
+ if( 0==zTarget ){
1000
+ webpage_error("Requires target=X");
1001
+ }
1002
+ login_check_credentials();
1003
+ eTgtType = attachment_target_type(zTarget, 1);
1004
+ switch( eTgtType ){
1005
+ default:
1006
+ case 0:
1007
+ webpage_error("Cannot resolve target=%h.", zTarget);
1008
+ break;
1009
+ case CFTYPE_FORUM:{
1010
+ int fpid;
1011
+ if( g.perm.AttachForum==0 ){
1012
+ login_needed(g.anon.AttachForum);
1013
+ return;
1014
+ }
1015
+ fpid = forumpost_head_rid2(zTarget);
1016
+ if( fpid<=0 ){
1017
+ webpage_error("Invalid forum post ID: %h", zTarget);
1018
+ }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
1019
+ webpage_error("Only admins can attach files to other users' "
1020
+ "forum posts.");
1021
+ }
1022
+ zTarget = zExtraFree = rid_to_uuid(fpid);
1023
+ noJsArgs[0] = zTarget;
1024
+ zTargetType = mprintf(
1025
+ "Forum post <a href=\"%R/forumpost/%S\">%.16h</a>",
1026
+ zTarget, zTarget
1027
+ );
1028
+ zTo = mprintf("%R/forumpost/%S", zTarget);
1029
+ break;
1030
+ }
1031
+ case CFTYPE_EVENT:{
1032
+ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1033
+ login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
1034
+ return;
1035
+ }
1036
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1037
+ zTarget) ){
1038
+ zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag"
1039
+ " WHERE tagname GLOB 'event-%q*'",
1040
+ zTarget);
1041
+ if( zTarget==0) fossil_redirect_home();
1042
+ }
1043
+ zTo = zFrom ? 0 : mprintf("%R/technote?name=%T", zTarget);
1044
+ zTargetType = mprintf("Tech-note <a href=\"%R/technote/%s\">%S</a>",
1045
+ zTarget, zTarget);
1046
+ noJsArgs[1] = zTarget;
1047
+ break;
1048
+ }
1049
+ case CFTYPE_TICKET:{
1050
+ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
1051
+ login_needed(g.anon.ApndTkt && g.anon.Attach);
1052
+ return;
1053
+ }
1054
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
1055
+ zTarget) ){
1056
+ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
1057
+ " WHERE tagname GLOB 'tkt-%q*'", zTarget);
1058
+ if( zTarget==0 ) fossil_redirect_home();
1059
+ }
1060
+ zTo = zFrom ? 0 : mprintf("%R/tktview/%t", zTarget);
1061
+ zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
1062
+ zTarget, zTarget);
1063
+ noJsArgs[2] = zTarget;
1064
+ break;
1065
+ }
1066
+ case CFTYPE_WIKI:{
1067
+ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1068
+ login_needed(g.anon.ApndWiki && g.anon.Attach);
1069
+ return;
1070
+ }
1071
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1072
+ zTarget) ){
1073
+ fossil_redirect_home();
1074
+ }
1075
+ zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
1076
+ zTargetType = mprintf(
1077
+ "Wiki page <a href=\"%R/wiki?name=%h\">%h</a>",
1078
+ zTarget, zTarget
1079
+ );
1080
+ noJsArgs[3] = zTarget;
1081
+ break;
1082
+ }
1083
+ }
1084
+
1085
+ db_begin_transaction();
1086
+
1087
+ style_set_current_feature("attach");
1088
+ style_header("Add Attachment");
1089
+ if( !goodCaptcha ){
1090
+ @ <p class="generalError">Error: Incorrect security code.</p>
1091
+ }
1092
+ @ <h2>Attachments for %s(zTargetType)</h2>
1093
+ attachment_list(zTarget, NULL,
1094
+ ATTACHLIST_SIZE | ATTACHLIST_HIDE_UNAPPROVED);
1095
+ attach_render_legacy_form(
1096
+ noJsArgs[0], noJsArgs[1], noJsArgs[2],
1097
+ noJsArgs[3], 0,
1098
+ zFrom ? zFrom : (zTo ? zTo : (zTo=mprintf("%R/home")))
1099
+ );
1100
+ @ <div id='attachadd-form-wrapper' class='hidden'>
1101
+ /* fossil.attach.js populates this DIV with the attachment widget,
1102
+ ** imports these hidden fields, and removes the legacy form. */
1103
+ @ <input type="hidden" name="target" value="%h(zTarget)">
1104
+ if( zFrom ){
1105
+ @ <input type="hidden" name="from" value="%h(zFrom)">
5311106
}
532
- @ <input type="hidden" name="from" value="%h(zFrom)">
533
- @ <input type="submit" name="ok" value="Add Attachment">
534
- @ <input type="submit" name="cancel" value="Cancel">
535
- @ </div>
1107
+ if( zTo ){
1108
+ @ <input type="hidden" name="to" value="%h(zTo)">
1109
+ }
5361110
captcha_generate(0);
537
- @ </form>
1111
+ login_insert_csrf_secret();
1112
+ @ </div>
1113
+ builtin_fossil_js_bundle_or("attach", NULL);
1114
+ db_end_transaction(0);
5381115
style_finish_page();
5391116
fossil_free(zTargetType);
5401117
fossil_free(zExtraFree);
1118
+ fossil_free(zTo);
5411119
}
5421120
5431121
/*
5441122
** WEBPAGE: ainfo
5451123
** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
6001178
&& db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
6011179
){
6021180
if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
6031181
zTktUuid = zTarget;
6041182
showDelMenu = g.perm.WrTkt;
605
- }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",zTarget) ){
1183
+ }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1184
+ zTarget) ){
6061185
if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
6071186
zWikiName = zTarget;
6081187
showDelMenu = g.perm.WrWiki;
609
- }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",zTarget) ){
1188
+ }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1189
+ zTarget) ){
6101190
if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
6111191
zTNUuid = zTarget;
6121192
showDelMenu = g.perm.Write && g.perm.WrWiki;
6131193
}
6141194
if( showDelMenu ){
@@ -632,11 +1212,13 @@
6321212
Blob cksum;
6331213
const char *zFile = zName;
6341214
6351215
if( !bUserIsOwner ){
6361216
if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
637
- webpage_error("Only admins can delete other users' attachments.");
1217
+ webpage_error(
1218
+ "Only admins can delete other users' attachments."
1219
+ );
6381220
}
6391221
}
6401222
db_begin_transaction();
6411223
blob_zero(&manifest);
6421224
for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
6561238
@ <p>The attachment below has been deleted.</p>
6571239
fossil_free(zNewDate);
6581240
}
6591241
6601242
if( P("del")
661
- && ((zForumPost && (bUserIsOwner || forumpost_may_close())) ||
662
- (zTktUuid && g.perm.WrTkt) ||
663
- (zWikiName && g.perm.WrWiki) ||
664
- (zTNUuid && g.perm.Write && g.perm.WrWiki))
1243
+ && ((zForumPost && (bUserIsOwner || forumpost_may_close()))
1244
+ || (zTktUuid && g.perm.WrTkt)
1245
+ || (zWikiName && g.perm.WrWiki)
1246
+ || (zTNUuid && g.perm.Write && g.perm.WrWiki))
6651247
){
6661248
form_begin(0, "%R/ainfo/%!S", zUuid);
6671249
@ <p>Confirm you want to delete the attachment shown below.
6681250
@ <input type="submit" name="confirm" value="Confirm">
6691251
login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
7111293
@ (%d(rid))
7121294
}
7131295
modPending = moderation_pending_www(rid);
7141296
if( zForumPost ){
7151297
@ <tr><th>Forum&nbsp;Post:</th>
716
- @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a></td></tr>
1298
+ @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a>\
1299
+ @ </td></tr>
7171300
}else if( zTktUuid ){
7181301
@ <tr><th>Ticket:</th>
7191302
@ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
7201303
}else if( zTNUuid ){
7211304
@ <tr><th>Tech Note:</th>
7221305
@ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
7231306
}else if( zWikiName ){
7241307
@ <tr><th>Wiki&nbsp;Page:</th>
725
- @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a></td></tr>
1308
+ @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a>\
1309
+ @ </td></tr>
7261310
}
7271311
@ <tr><th>Date:</th><td>
7281312
hyperlink_to_date(zDate, "</td></tr>");
7291313
@ <tr><th>User:</th><td>
7301314
hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
7351319
}
7361320
@ <tr><th>Filename:</th><td>%h(zName)</td></tr>
7371321
if( g.perm.Setup ){
7381322
@ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
7391323
}
740
- @ <tr><th valign="top">Description:</th><td valign="top">%h(zDesc)</td></tr>
1324
+ @ <tr><th valign="top">Description:</th>\
1325
+ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). */
1326
+ @ <td valign="top">%h(zDesc)</td></tr>
7411327
@ </table>
7421328
7431329
if( modPending && (isModerator || bUserIsOwner) ){
7441330
@ <div class="section">Moderation</div>
7451331
@ <blockquote>
7461332
form_begin(0, "%R/ainfo/%s", zUuid);
7471333
@ <label><input type="radio" name="modaction" value="delete">
7481334
@ Delete this attachment</label><br>
7491335
if( isModerator ){
750
- @ <label><input type="radio" name="modaction" value="approve">
751
- @ Approve this attachment</label><br>
1336
+#if 0
1337
+ /* TODO/FIXME (2026-06-03): only allow approval of an attachment
1338
+ ** if its target has been approved. Without this, we can end up
1339
+ ** with stale attachments which refer to rejected targets. We
1340
+ ** need a type-specific RID/UUID here, which requires
1341
+ ** refactoring above to get it. */
1342
+ const int tgtid = 0;
1343
+ if( moderation_pending(tgtid) ){
1344
+ @ <label><input type="radio" name="modaction" \
1345
+ @ disabled value="approve">
1346
+ @ <span class='modpending'>Cannot approve:
1347
+ @ target is pending moderation</span>\
1348
+ @ </label><br>
1349
+ }else
1350
+#else
1351
+ {
1352
+ @ <label><input type="radio" name="modaction" value="approve">
1353
+ @ Approve this attachment</label><br>
1354
+ }
1355
+#endif
7521356
}
7531357
@ <input type="submit" value="Submit">
7541358
login_insert_csrf_secret();
7551359
@ </form>
7561360
@ </blockquote>
@@ -767,11 +1371,12 @@
7671371
const char *z;
7681372
content_get(ridSrc, &attach);
7691373
blob_to_utf8_no_bom(&attach, 0);
7701374
z = blob_str(&attach);
7711375
if( zLn ){
772
- output_text_with_line_numbers(z, blob_size(&attach), zName, zLn, 1);
1376
+ output_text_with_line_numbers(z, blob_size(&attach),
1377
+ zName, zLn, 1);
7731378
}else{
7741379
@ <pre>
7751380
@ %h(z)
7761381
@ </pre>
7771382
}
@@ -798,10 +1403,13 @@
7981403
*/
7991404
#define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
8001405
#define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
8011406
#define ATTACHLIST_SIZE 0x04 /* add size */
8021407
#define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
1408
+#define ATTACHLIST_DETAILS_CLOSED 0x10 /* Wrap in a closed DETAILS element */
1409
+#define ATTACHLIST_DETAILS_OPEN 0x20 /* Wrap in an open DETAILS element */
1410
+#define ATTACHLIST_HIDE_EMPTY 0x40 /* Skip if size<1 */
8031411
#endif
8041412
8051413
/*
8061414
** Output HTML to show a list of attachments.
8071415
*/
@@ -810,19 +1418,25 @@
8101418
const char *zHeader, /* Header to display with attachments */
8111419
const int flags /* ATTACHLIST_... flags */
8121420
){
8131421
int cnt = 0;
8141422
char szBuf[36] = {0}; /* scratchpad for attachment size value */
815
- const char * zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
1423
+ const char *zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
8161424
? " target=\"_blank\"" : "";
1425
+ const int bUseDetail = flags &
1426
+ (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN);
8171427
Stmt q;
1428
+
8181429
db_prepare(&q,
819
- "SELECT datetime(mtime,toLocal()), filename, user,"
820
- " (SELECT uuid FROM blob WHERE rid=attachid), src, target, "
821
- " attachid "
822
- " FROM attachment"
823
- " WHERE isLatest AND src!='' AND target=%Q"
1430
+ "SELECT datetime(mtime,toLocal()), a.filename, a.user,"
1431
+ " b1.uuid, a.src, a.target, a.attachid, b2.size\n"
1432
+ " FROM attachment a, blob b1, blob b2\n"
1433
+ " WHERE a.isLatest\n"
1434
+ " AND a.src IS NOT NULL\n"
1435
+ " AND a.target=%Q\n"
1436
+ " AND b1.rid=a.attachid\n"
1437
+ " AND b2.uuid=a.src\n"
8241438
" ORDER BY mtime DESC",
8251439
zTarget
8261440
);
8271441
while( db_step(&q)==SQLITE_ROW ){
8281442
const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
8321446
const char *zSrc = db_column_text(&q, 4);
8331447
const char *zTarget = db_column_text(&q, 5);
8341448
const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
8351449
const char *zTypeArg = 0; /* URL arg name for /attachdownload */
8361450
const int aid = db_column_int(&q, 6);
837
- const int iAType = attachment_target_type(zTarget);
1451
+ const int sz = db_column_int(&q, 7);
8381452
if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
8391453
&& moderation_pending(aid)
8401454
&& !moderation_user_could(aid, 1, 0) ){
8411455
continue;
1456
+ }
1457
+ if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){
1458
+ /* Deleted or phantom items. */
1459
+ continue;
8421460
}
8431461
if( cnt==0 ){
844
- @ <section class='attachlist'>
1462
+ if( bUseDetail ){
1463
+ @ <details class='attachlist'
1464
+ if( ATTACHLIST_DETAILS_OPEN & flags ){
1465
+ @ open
1466
+ }
1467
+ @ >
1468
+ }else{
1469
+ @ <section class='attachlist'>
1470
+ }
8451471
if( flags & ATTACHLIST_HRULE_ABOVE ){
8461472
@ <hr>
8471473
}
848
- @ %s(zHeader)
1474
+ if( bUseDetail ){
1475
+ @ <summary>%s(zHeader)</summary>
1476
+ }else{
1477
+ @ %s(zHeader)
1478
+ }
8491479
@ <ul>
8501480
}
8511481
cnt++;
852
- switch( iAType ){
1482
+ switch( attachment_target_type(zTarget, 1) ){
8531483
case CFTYPE_TICKET: zTypeArg = "tkt"; break;
8541484
case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
8551485
case CFTYPE_EVENT: zTypeArg = "technote"; break;
8561486
case CFTYPE_WIKI:
8571487
default: zTypeArg = "page"; break;
8581488
}
8591489
@ <li>
8601490
@ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
8611491
if( flags & ATTACHLIST_SIZE ){
862
- const int sz = db_int(0,"SELECT size FROM blob WHERE uuid=%Q", zSrc);
8631492
sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
8641493
}
8651494
@ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
8661495
@&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
8671496
@ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
8701499
moderation_pending_www(aid);
8711500
@ </li>
8721501
}
8731502
if( cnt ){
8741503
@ </ul>
875
- @ </section>
1504
+ if( bUseDetail ){
1505
+ @ </details>
1506
+ }else{
1507
+ @ </section>
1508
+ }
8761509
}
8771510
db_finalize(&q);
8781511
}
8791512
8801513
/*
@@ -1023,15 +1656,204 @@
10231656
}
10241657
for(i = 2; i < g.argc; ++i){
10251658
const char *zPage = g.argv[i];
10261659
db_bind_text(&q, ":tgtname", zPage);
10271660
while(SQLITE_ROW == db_step(&q)){
1028
- const char * zTime = db_column_text(&q, 0);
1029
- const char * zSrc = db_column_text(&q, 1);
1030
- const char * zTarget = db_column_text(&q, 2);
1031
- const char * zName = db_column_text(&q, 3);
1661
+ const char *zTime = db_column_text(&q, 0);
1662
+ const char *zSrc = db_column_text(&q, 1);
1663
+ const char *zTarget = db_column_text(&q, 2);
1664
+ const char *zName = db_column_text(&q, 3);
10321665
printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
10331666
}
10341667
db_reset(&q);
10351668
}
10361669
db_finalize(&q);
10371670
}
1671
+
1672
+/*
1673
+** Renders the list of attachments for artifact pManifest as JSON to
1674
+** blob pOut. If pManifest->type is not one of (CFTYPE_TICKET,
1675
+** CFTYPE_FORUM, CFTYPE_EVENT, CFTYPE_WIKI) then it behaves as if the
1676
+** result set is empty.
1677
+**
1678
+** If there are no matching attachments then its behavior depends on
1679
+** emptyPolicy:
1680
+**
1681
+** <0 = emit a JSON NULL
1682
+** 0 = emit no output
1683
+** >0 = emit an empty JSON array
1684
+**
1685
+** If bLatestOnly is true then only the most recent entry for a given
1686
+** attachment is emitted, else all versions are emitted in descending
1687
+** mtime order.
1688
+**
1689
+** Returns the number of attachments.
1690
+**
1691
+** Output format:
1692
+**
1693
+** [{
1694
+** "uuid": attachment artifact hash,
1695
+** "src": hash of the attachment blob,
1696
+** "target": wiki page name or ticket/event ID,
1697
+** "filename": filename of attachment,
1698
+** "mtime": ISO-8601 timestamp UTC,
1699
+** "isLatest": true if this is the latest version of this file
1700
+** else false,
1701
+** }, ...once per attachment]
1702
+**
1703
+*/
1704
+int attachments_to_json(const Manifest *pManifest,
1705
+ Blob *pOut, int bLatestOnly,
1706
+ int emptyPolicy){
1707
+ int i = 0;
1708
+ Stmt q = empty_Stmt;
1709
+ char *zToFree = 0;
1710
+ const char *zTgt = 0;
1711
+ switch(pManifest->type){
1712
+ case CFTYPE_FORUM: zTgt = zToFree = rid_to_uuid(pManifest->rid);
1713
+ break;
1714
+ case CFTYPE_WIKI: zTgt = pManifest->zWikiTitle; break;
1715
+ case CFTYPE_EVENT: zTgt = pManifest->zEventId; break;
1716
+ case CFTYPE_TICKET: zTgt = pManifest->zTicketUuid; break;
1717
+ default:
1718
+ goto empty_result;
1719
+ }
1720
+ db_prepare(&q,
1721
+ "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1722
+ " b2.size, b1.uuid, a.user, a.comment\n"
1723
+ " FROM attachment a, blob b1, blob b2\n"
1724
+ " WHERE a.target=%Q\n"
1725
+ " AND a.src IS NOT NULL\n"
1726
+ " AND b1.rid=a.attachid\n"
1727
+ " AND b2.uuid=a.src\n"
1728
+ " AND (a.isLatest OR %d)\n"
1729
+ " ORDER BY a.target, a.isLatest DESC, a.mtime DESC\n",
1730
+ zTgt, !bLatestOnly
1731
+ );
1732
+ while(SQLITE_ROW == db_step(&q)){
1733
+ const char *zTime = db_column_text(&q, 0);
1734
+ const char *zSrc = db_column_text(&q, 1);
1735
+ const char *zTarget = db_column_text(&q, 2);
1736
+ const char *zName = db_column_text(&q, 3);
1737
+ const int isLatest = db_column_int(&q, 4);
1738
+ const int sz = db_column_int(&q, 5);
1739
+ const char *zUuid = db_column_text(&q, 6);
1740
+ const char *zUser = db_column_text(&q, 7);
1741
+ const char *zComment = db_column_text(&q, 8);
1742
+ if(!i++){
1743
+ blob_append_char(pOut, '[');
1744
+ }else{
1745
+ blob_append_char(pOut, ',');
1746
+ }
1747
+ blob_appendf(
1748
+ pOut,
1749
+ "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1750
+ "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1751
+ "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
1752
+ zUuid, zSrc, zTarget,
1753
+ zName, sz, zTime, isLatest ? "true" : "false",
1754
+ zUser
1755
+ );
1756
+ if( zComment && zComment[0] ){
1757
+ blob_appendf(pOut, "%!j", zComment);
1758
+ }else{
1759
+ blob_append_literal(pOut, "null");
1760
+ }
1761
+ blob_append_char(pOut, '}');
1762
+ }
1763
+ fossil_free(zToFree);
1764
+ db_finalize(&q);
1765
+ if(!i){
1766
+ empty_result:
1767
+ if( emptyPolicy>0 ){
1768
+ blob_append_literal(pOut, "[]");
1769
+ }else if( emptyPolicy<0 ){
1770
+ blob_append_literal(pOut, "null");
1771
+ }
1772
+ }else{
1773
+ blob_append_char(pOut, ']');
1774
+ }
1775
+ return i;
1776
+}
1777
+
1778
+/*
1779
+** COMMAND: test-attachment-target
1780
+**
1781
+** Usage: %fossil test-attachment-target TARGET_ID...
1782
+*/
1783
+void test_attachment_target_type_cmd(void){
1784
+ int i;
1785
+ verify_all_options();
1786
+ db_find_and_open_repository(0, 0);
1787
+ if( g.argc<3 ){
1788
+ usage("test-attachment-target TARGET_ID");
1789
+ return;
1790
+ }
1791
+ for( i = 2; i < g.argc; ++i ){
1792
+ const char *zTarget = g.argv[i];
1793
+ const int rid = attachment_target_rid(zTarget, 0);
1794
+ const int type = attachment_target_type(zTarget, 0);
1795
+ const char *zType = "<invalid>";
1796
+ switch(type){
1797
+ case CFTYPE_EVENT: zType = "technote"; break;
1798
+ case CFTYPE_FORUM: zType = "forumpost"; break;
1799
+ case CFTYPE_TICKET: zType = "ticket"; break;
1800
+ case CFTYPE_WIKI: zType = "wiki"; break;
1801
+ }
1802
+ fossil_print("%-20s = %-9s #%d %z\n",
1803
+ zTarget, zType, rid,
1804
+ rid>0 ? rid_to_uuid(rid) : 0);
1805
+ }
1806
+}
1807
+
1808
+
1809
+/*
1810
+** COMMAND: test-attachments-to-json
1811
+**
1812
+** Usage: %fossil test-attachments-to-json TARGET_ID...
1813
+**
1814
+** Options:
1815
+** --old List all versions of attachments. Default is to
1816
+** list only the latest.
1817
+** --full Require a full target ID, not a prefix.
1818
+**
1819
+** Emits a JSON array of attachments for the given attachment targets.
1820
+** The given IDs must be wiki page names, ticket hashes, tech-note
1821
+** hashes, or forum post hashes. By default it accepts hash prefixes
1822
+** but does no detection of ambiguity or cross-type prefix collisions
1823
+** so may emit curious results if given short, colliding IDs.
1824
+*/
1825
+void test_attachments_to_json_cmd(void){
1826
+ const int emptyPolicy = 1;
1827
+ const int bLatestOnly = find_option("old",0,0)==0;
1828
+ const int bFullId = find_option("full",0,0)!=0;
1829
+ int i;
1830
+
1831
+ verify_all_options();
1832
+ db_find_and_open_repository(0, 0);
1833
+ if( g.argc<3 ){
1834
+ usage("test-attachments-to-json TARGET_ID");
1835
+ return;
1836
+ }
1837
+ for( i = 2; i < g.argc; ++i ){
1838
+ const char *zTarget = g.argv[i];
1839
+ const int rid = attachment_target_rid(zTarget, bFullId);
1840
+ if( 0==rid ){
1841
+ fossil_print("** cannot resolve %s\n", zTarget);
1842
+ }else{
1843
+ Blob b = BLOB_INITIALIZER;
1844
+ Manifest *pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1845
+ assert( pManifest );
1846
+ attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1847
+ fossil_print("Attachments for %s: ", zTarget);
1848
+ if( b.nUsed ){
1849
+ char *zPretty = db_text(0,"SELECT json_pretty(%B)", &b);
1850
+ fossil_print("%s\n", zPretty);
1851
+ fossil_free(zPretty);
1852
+ }else{
1853
+ fossil_print("none\n");
1854
+ }
1855
+ blob_reset(&b);
1856
+ manifest_destroy(pManifest);
1857
+ }
1858
+ }
1859
+}
10381860
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
22 #include <assert.h>
23
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28 ** distinguish the target type.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29 **
30 ** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
31 ** if needed, they resolve zTarget using forumpost_head_rid2() so that
32 ** they get the RID of the earliest version of the post, as that is
33 ** the only one which attachments should target.
 
34 */
35 int attachment_target_type(const char *zTarget){
36 static Stmt q = empty_Stmt_m;
37 int rc = 0;
38 if( forumpost_head_rid2(zTarget)>0 ){
 
 
 
 
39 return CFTYPE_FORUM;
40 }
41 if( !q.pStmt ){
42 db_static_prepare(
43 &q,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44 "SELECT CASE "
45 "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
46 "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
47 "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
 
 
 
48 "ELSE 0 END",
49 CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
 
 
50 );
51 }
52 db_bind_text(&q, ":tgt", zTarget);
53 if( SQLITE_ROW==db_step(&q) ){
54 rc = db_column_int(&q, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55 }
56 db_reset(&q);
57 return rc;
 
 
 
 
 
 
 
 
 
 
 
58 }
59
60 /*
61 ** WEBPAGE: attachlist
62 ** List attachments.
@@ -69,20 +216,21 @@
69 ** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
70 **
71 ** If none are given, all attachments are listed. If one is given, only
72 ** attachments for the designated technote, ticket or wiki page are shown.
73 **
74 ** HASH may be just a prefix of the relevant technical note or ticket
75 ** artifact hash, in which case all attachments of all technical notes or
76 ** tickets with the prefix will be listed. Forum posts, on the other hand,
77 ** require a unique hash prefix.
78 */
79 void attachlist_page(void){
80 const char *zPage = P("page");
81 const char *zTkt = P("tkt");
82 const char *zTechNote = P("technote");
83 const char *zForumPost = P("forumpost");
 
84 Blob sql;
85 Stmt q;
86
87 if( zPage && zTkt ) zTkt = 0;
88 login_check_credentials();
@@ -102,32 +250,47 @@
102 if( fnid<=0 ){
103 webpage_error("Invalid forum post ID: %h", zForumPost);
104 }
105 blob_append_sql(&sql, " WHERE target="
106 "(SELECT uuid FROM blob WHERE rid=%d)", fnid);
 
 
107 }else if( zPage ){
108 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
109 style_header("Attachments To Wiki page %h", zPage);
110 blob_append_sql(&sql, " WHERE target=%Q", zPage);
 
 
111 }else if( zTkt ){
112 if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
113 style_header("Attachments To Ticket %S", zTkt);
114 blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
 
 
115 }else if( zTechNote ){
116 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
117 style_header("Attachments To Tech Note %S", zTechNote);
118 blob_append_sql(&sql, " WHERE target GLOB '%q*'",
119 zTechNote);
 
 
120 }else{
121 if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
122 login_needed(g.anon.RdTkt || g.anon.RdWiki);
123 return;
124 }
125 style_header("All Attachments");
126 }
127 blob_append_sql(&sql, " ORDER BY mtime DESC");
128 db_prepare(&q, "%s", blob_sql_text(&sql));
 
 
 
 
 
 
 
129 @ <ol>
130 while( db_step(&q)==SQLITE_ROW ){
131 const char *zDate;
132 const char *zSrc;
133 const char *zTarget;
@@ -137,10 +300,11 @@
137 const char *zUuid;
138 const char *zDispUser;
139 const int attachid = db_column_int(&q, 7);
140 int type;
141 int i;
 
142 char *zUrlTail = 0;
143
144 if( moderation_pending(attachid)
145 && !moderation_user_could(attachid, 1, 0) ){
146 /* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
160 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
161 zFilename = &zFilename[i+1];
162 i = -1;
163 }
164 }
165 type = attachment_target_type(zTarget);
 
166 switch( type ){
167 case CFTYPE_TICKET:
168 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
169 break;
170 case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
176 case CFTYPE_WIKI:
177 zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
178 break;
179 }
180 @ <li><p>
181 @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>
 
 
 
182 moderation_pending_www(attachid);
183 @ <br><a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
184 @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]<br>
 
 
 
 
 
185 if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
186 if( zComment && zComment[0] ){
187 @ %!W(zComment)<br>
 
 
 
 
188 }
189 if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
190 if( zSrc==0 || zSrc[0]==0 ){
191 zSrc = "Deleted from";
192 }else {
193 zSrc = "Added to";
194 }
195 switch( type ){
@@ -362,41 +539,81 @@
362 Manifest *pManifest;
363
364 db_begin_transaction();
365 blob_init(&content, aContent, szContent);
366 pManifest = manifest_parse(&content, 0, 0);
 
367 manifest_destroy(pManifest);
368 blob_init(&content, aContent, szContent);
369 if( pManifest ){
370 blob_compress(&content, &content);
371 addCompress = 1;
372 }
373 rid = content_put_ex(&content, 0, 0, 0, needModerator);
374 zUUID = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
375 blob_zero(&manifest);
376 for(i=n=0; zName[i]; i++){
377 if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
378 }
379 zName += n;
380 if( zName[0]==0 ) zName = "unknown";
381 blob_appendf(&manifest, "A %F%s %F %s\n",
382 zName, addCompress ? ".gz" : "", zTarget, zUUID);
383 while( fossil_isspace(zComment[0]) ) zComment++;
384 n = strlen(zComment);
385 while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
386 if( n>0 ){
387 blob_appendf(&manifest, "C %#F\n", n, zComment);
 
 
388 }
389 zDate = date_in_standard_format("now");
390 blob_appendf(&manifest, "D %s\n", zDate);
391 blob_appendf(&manifest, "U %F\n", login_name());
392 md5sum_blob(&manifest, &cksum);
393 blob_appendf(&manifest, "Z %b\n", &cksum);
394 attach_put(&manifest, rid, needModerator);
395 assert( blob_is_reset(&manifest) );
396 db_end_transaction(0);
397 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
399 /*
400 ** WEBPAGE: attachadd
401 ** Add a new attachment.
402 **
@@ -404,33 +621,54 @@
404 ** page=WIKIPAGE
405 ** technote=HASH
406 ** forumpost=HASH
407 ** from=URL
408 **
 
 
 
 
 
 
 
409 */
410 void attachadd_page(void){
411 const char *zPage = P("page");
412 const char *zForumPost = P("forumpost");
413 const char *zTkt = P("tkt");
414 const char *zTechNote = P("technote");
415 const char *zFrom = P("from");
416 const char *aContent = P("f");
417 const char *zName = PD("f:filename","unknown");
418 const char *zComment = PD("comment", "");
419 const char *zTarget;
420 char * zTo = 0;
 
421 char *zTargetType = 0;
422 char *zExtraFree = 0;
423 int szContent = atoi(PD("f:bytes","0"));
424 int goodCaptcha = 1;
425 int szLimit = 0;
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427 if( zFrom==0 ) zFrom = mprintf("%R/home");
428 if( P("cancel") ) cgi_redirect(zFrom);
429 if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
430 webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
431 " or technote=X");
432 }
433 login_check_credentials();
434 if( zForumPost ){
435 int fpid;
436 if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
445 "forum posts.");
446 }
447 zTarget = zExtraFree = rid_to_uuid(fpid);
448 zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
449 zTarget, zForumPost);
450 zTo = 1
451 ? mprintf("%R/forumpost/%S", zTarget)
452 : mprintf("%R/attachview?forumpost=%T&file=%T",
453 zTarget, zName)
454 /* Or we could return directly to the forum post. */;
455 }else if( zPage ){
456 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
457 login_needed(g.anon.ApndWiki && g.anon.Attach);
458 return;
459 }
460 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
461 fossil_redirect_home();
462 }
463 zTarget = zPage;
464 zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%h\">%h</a>",
465 zPage, zPage);
 
466 }else if ( zTechNote ){
467 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
468 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
469 return;
470 }
@@ -474,11 +709,11 @@
474 if( zTechNote==0) fossil_redirect_home();
475 }
476 zTarget = zTechNote;
477 zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
478 zTechNote, zTechNote);
479
480 }else{
481 assert( zTkt );
482 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
483 login_needed(g.anon.ApndTkt && g.anon.Attach);
484 return;
@@ -487,21 +722,25 @@
487 zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
488 " WHERE tagname GLOB 'tkt-%q*'", zTkt);
489 if( zTkt==0 ) fossil_redirect_home();
490 }
491 zTarget = zTkt;
492 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
493 zTkt, zTkt);
 
494 }
495 szLimit = db_get_int("attachment-size-limit", 0);
496 if( szContent<0 || (szLimit && szContent>szLimit) ){
497 /* This check must be done late so that zTargetType is set up. */
498 @ <p class="generalError">Attachment %h(zName) is too large.
499 @ <a href="%R/help/attachment-size-limit">Limit</a> is
500 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
501 /* Fall through and render form. */
502 }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
 
 
 
503 int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
504 (zTkt!=0 && ticket_need_moderation(0)) ||
505 (zPage!=0 && wiki_need_moderation(0));
506 attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
507 cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
511 style_header("Add Attachment");
512 if( !goodCaptcha ){
513 @ <p class="generalError">Error: Incorrect security code.</p>
514 }
515 @ <h2>Add Attachment To %s(zTargetType)</h2>
516 form_begin("enctype='multipart/form-data'", "%R/attachadd");
517 @ <div>
518 @ File to Attach:
519 @ <input type="file" name="f" size="60"><br>
520 @ Description:<br>
521 @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
522 @ >%h(zComment)</textarea><br>
523 if( zForumPost ){
524 @ <input type="hidden" name="forumpost" value="%h(zTarget)">
525 }else if( zTkt ){
526 @ <input type="hidden" name="tkt" value="%h(zTkt)">
527 }else if( zTechNote ){
528 @ <input type="hidden" name="technote" value="%h(zTechNote)">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529 }else{
530 @ <input type="hidden" name="page" value="%h(zPage)">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531 }
532 @ <input type="hidden" name="from" value="%h(zFrom)">
533 @ <input type="submit" name="ok" value="Add Attachment">
534 @ <input type="submit" name="cancel" value="Cancel">
535 @ </div>
536 captcha_generate(0);
537 @ </form>
 
 
 
538 style_finish_page();
539 fossil_free(zTargetType);
540 fossil_free(zExtraFree);
 
541 }
542
543 /*
544 ** WEBPAGE: ainfo
545 ** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
600 && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
601 ){
602 if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
603 zTktUuid = zTarget;
604 showDelMenu = g.perm.WrTkt;
605 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",zTarget) ){
 
606 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
607 zWikiName = zTarget;
608 showDelMenu = g.perm.WrWiki;
609 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",zTarget) ){
 
610 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
611 zTNUuid = zTarget;
612 showDelMenu = g.perm.Write && g.perm.WrWiki;
613 }
614 if( showDelMenu ){
@@ -632,11 +1212,13 @@
632 Blob cksum;
633 const char *zFile = zName;
634
635 if( !bUserIsOwner ){
636 if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
637 webpage_error("Only admins can delete other users' attachments.");
 
 
638 }
639 }
640 db_begin_transaction();
641 blob_zero(&manifest);
642 for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
656 @ <p>The attachment below has been deleted.</p>
657 fossil_free(zNewDate);
658 }
659
660 if( P("del")
661 && ((zForumPost && (bUserIsOwner || forumpost_may_close())) ||
662 (zTktUuid && g.perm.WrTkt) ||
663 (zWikiName && g.perm.WrWiki) ||
664 (zTNUuid && g.perm.Write && g.perm.WrWiki))
665 ){
666 form_begin(0, "%R/ainfo/%!S", zUuid);
667 @ <p>Confirm you want to delete the attachment shown below.
668 @ <input type="submit" name="confirm" value="Confirm">
669 login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
711 @ (%d(rid))
712 }
713 modPending = moderation_pending_www(rid);
714 if( zForumPost ){
715 @ <tr><th>Forum&nbsp;Post:</th>
716 @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a></td></tr>
 
717 }else if( zTktUuid ){
718 @ <tr><th>Ticket:</th>
719 @ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
720 }else if( zTNUuid ){
721 @ <tr><th>Tech Note:</th>
722 @ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
723 }else if( zWikiName ){
724 @ <tr><th>Wiki&nbsp;Page:</th>
725 @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a></td></tr>
 
726 }
727 @ <tr><th>Date:</th><td>
728 hyperlink_to_date(zDate, "</td></tr>");
729 @ <tr><th>User:</th><td>
730 hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
735 }
736 @ <tr><th>Filename:</th><td>%h(zName)</td></tr>
737 if( g.perm.Setup ){
738 @ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
739 }
740 @ <tr><th valign="top">Description:</th><td valign="top">%h(zDesc)</td></tr>
 
 
741 @ </table>
742
743 if( modPending && (isModerator || bUserIsOwner) ){
744 @ <div class="section">Moderation</div>
745 @ <blockquote>
746 form_begin(0, "%R/ainfo/%s", zUuid);
747 @ <label><input type="radio" name="modaction" value="delete">
748 @ Delete this attachment</label><br>
749 if( isModerator ){
750 @ <label><input type="radio" name="modaction" value="approve">
751 @ Approve this attachment</label><br>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752 }
753 @ <input type="submit" value="Submit">
754 login_insert_csrf_secret();
755 @ </form>
756 @ </blockquote>
@@ -767,11 +1371,12 @@
767 const char *z;
768 content_get(ridSrc, &attach);
769 blob_to_utf8_no_bom(&attach, 0);
770 z = blob_str(&attach);
771 if( zLn ){
772 output_text_with_line_numbers(z, blob_size(&attach), zName, zLn, 1);
 
773 }else{
774 @ <pre>
775 @ %h(z)
776 @ </pre>
777 }
@@ -798,10 +1403,13 @@
798 */
799 #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
800 #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
801 #define ATTACHLIST_SIZE 0x04 /* add size */
802 #define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
 
 
 
803 #endif
804
805 /*
806 ** Output HTML to show a list of attachments.
807 */
@@ -810,19 +1418,25 @@
810 const char *zHeader, /* Header to display with attachments */
811 const int flags /* ATTACHLIST_... flags */
812 ){
813 int cnt = 0;
814 char szBuf[36] = {0}; /* scratchpad for attachment size value */
815 const char * zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
816 ? " target=\"_blank\"" : "";
 
 
817 Stmt q;
 
818 db_prepare(&q,
819 "SELECT datetime(mtime,toLocal()), filename, user,"
820 " (SELECT uuid FROM blob WHERE rid=attachid), src, target, "
821 " attachid "
822 " FROM attachment"
823 " WHERE isLatest AND src!='' AND target=%Q"
 
 
 
824 " ORDER BY mtime DESC",
825 zTarget
826 );
827 while( db_step(&q)==SQLITE_ROW ){
828 const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
832 const char *zSrc = db_column_text(&q, 4);
833 const char *zTarget = db_column_text(&q, 5);
834 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
835 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
836 const int aid = db_column_int(&q, 6);
837 const int iAType = attachment_target_type(zTarget);
838 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
839 && moderation_pending(aid)
840 && !moderation_user_could(aid, 1, 0) ){
841 continue;
 
 
 
 
842 }
843 if( cnt==0 ){
844 @ <section class='attachlist'>
 
 
 
 
 
 
 
 
845 if( flags & ATTACHLIST_HRULE_ABOVE ){
846 @ <hr>
847 }
848 @ %s(zHeader)
 
 
 
 
849 @ <ul>
850 }
851 cnt++;
852 switch( iAType ){
853 case CFTYPE_TICKET: zTypeArg = "tkt"; break;
854 case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
855 case CFTYPE_EVENT: zTypeArg = "technote"; break;
856 case CFTYPE_WIKI:
857 default: zTypeArg = "page"; break;
858 }
859 @ <li>
860 @ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
861 if( flags & ATTACHLIST_SIZE ){
862 const int sz = db_int(0,"SELECT size FROM blob WHERE uuid=%Q", zSrc);
863 sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
864 }
865 @ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
866 @&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
867 @ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
870 moderation_pending_www(aid);
871 @ </li>
872 }
873 if( cnt ){
874 @ </ul>
875 @ </section>
 
 
 
 
876 }
877 db_finalize(&q);
878 }
879
880 /*
@@ -1023,15 +1656,204 @@
1023 }
1024 for(i = 2; i < g.argc; ++i){
1025 const char *zPage = g.argv[i];
1026 db_bind_text(&q, ":tgtname", zPage);
1027 while(SQLITE_ROW == db_step(&q)){
1028 const char * zTime = db_column_text(&q, 0);
1029 const char * zSrc = db_column_text(&q, 1);
1030 const char * zTarget = db_column_text(&q, 2);
1031 const char * zName = db_column_text(&q, 3);
1032 printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
1033 }
1034 db_reset(&q);
1035 }
1036 db_finalize(&q);
1037 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
22 #include <assert.h>
23
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot distinguish
28 ** the target type.
29 **
30 ** zTarget is an attachment target name: wiki page name, tech-note ID,
31 ** ticket ID, or forumpost hash.
32 **
33 ** If bFull is true then it requires zTarget to be a full ID for
34 ** tech-notes and tickets, otherwise such IDs may be prefixes. If
35 ** bFull is false then tech-notes and tickets will perform a prefix
36 ** match, but it is up to the caller to provide enough of a prefix to
37 ** rule out ambiguity[^1]. When called repeatedly, this routine can
38 ** run a bit faster and more efficiently if bFull is true, but some
39 ** historical use cases call for prefix matches.
40 **
41 ** Wiki page names always require an exact match.
42 **
43 ** Forum posts are a special case:
44 **
45 ** - They ignore the bFull flag. That is, they will do prefix matches
46 ** but will not match an ambiguous prefix.
47 **
48 ** - It is up to the caller to, if needed, resolve zTarget using
49 ** forumpost_head_rid2() to resolve the RID of the earliest version
50 ** of the post, as that is the only one which attachments should
51 ** target.
52 **
53 ** [^1]: Historically (from the perspective of 2026-06) attachment
54 ** target lookups have used GLOB prefix matching but have taken no
55 ** measures to ensure that the prefix is unambiguous. Ergo we do the
56 ** same here. It is assumed that the caller passes enough of a prefix
57 ** to be unambiguous and that's worked out fine so far.
58 */
59 int attachment_target_type(const char *zTarget, int bFull){
60 if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
61 return 0;
62 }
63 if( symbolic_name_to_rid(zTarget, "f")>0 ){
64 /* Check forum posts first because they are the most likely target
65 ** as of 2026. We should arguably use something more
66 ** specialized/efficient than symbolic_name_to_rid(). */
67 return CFTYPE_FORUM;
68 }
69 if( bFull ){
70 static Stmt q = empty_Stmt_m;
71 int rc = 0;
72 if( !q.pStmt ){
73 db_static_prepare(
74 &q,
75 "SELECT CASE "
76 /* Ordered by presumed likelihood of attachments. */
77 "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
78 "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
79 "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
80 "ELSE 0 END",
81 CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
82 );
83 }
84 db_bind_text(&q, ":tgt", zTarget);
85 if( SQLITE_ROW==db_step(&q) ){
86 rc = db_column_int(&q, 0);
87 }
88 db_reset(&q);
89 return rc;
90 }else{
91 return db_int(
92 0,
93 "SELECT CASE "
94 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
95 " THEN %d\n"
96 "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
97 " THEN %d\n"
98 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
99 " THEN %d\n"
100 "ELSE 0 END",
101 zTarget, CFTYPE_TICKET,
102 zTarget, CFTYPE_WIKI,
103 zTarget, CFTYPE_EVENT
104 );
105 }
106 }
107
108 /*
109 ** Given an attachment target name, returns the target's blob.rid.
110 ** zTarget and bFull work as described for attachment_target_type().
111 **
112 ** For forum posts, this always returns the RID of the first version
113 ** of the post, as attachments should always target that instance.
114 */
115 int attachment_target_rid(const char *zTarget, int bFull){
116 int rid = 0;
117 const int eType = attachment_target_type(zTarget, bFull);
118 switch(eType){
119 case CFTYPE_TICKET:
120 case CFTYPE_EVENT:{
121 const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
122 rid = db_int(
123 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
124 "WHERE tagname %s '%s-%q%s'\n"
125 "AND x.tagtype>0\n"
126 "AND x.tagid=t.tagid\n"
127 "AND x.rid=b.rid\n"
128 "ORDER BY x.mtime DESC",
129 bFull ? "=" : "GLOB"/*safe-for-%s*/,
130 zTagPrefix/*safe-for-%s*/,
131 zTarget,
132 bFull ? "" : "*"/*safe-for-%s*/
133 );
134 break;
135 }
136 case CFTYPE_FORUM:
137 rid = db_int(
138 0, "SELECT f.fpid FROM forumpost f, blob b\n"
139 "WHERE f.fpid=b.rid\n"
140 "AND b.uuid %s '%q%s'",
141 bFull ? "=" : "GLOB"/*safe-for-%s*/,
142 zTarget,
143 bFull ? "" : "*"/*safe-for-%s*/
144 );
145 if( rid>0 ){
146 rid = forumpost_head_rid(rid);
147 }
148 break;
149 case CFTYPE_WIKI:
150 rid = db_int(
151 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
152 "WHERE tagname='wiki-%q'\n"
153 "AND x.tagtype>0\n"
154 "AND x.tagid=t.tagid\n"
155 "AND x.rid=b.rid\n"
156 "ORDER BY x.mtime DESC",
157 zTarget
158 );
159 break;
160 default:
161 break;
162 }
163 return rid;
164 }
165
166 /*
167 ** For a given aritfact ID and type (from the CFTYPE_xyz enum),
168 ** returns true if the current user could hypothetically apply and
169 ** attachment to it, else returns 0.
170 **
171 ** The rid is currently only relevant when eArtifactType is
172 ** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
173 ** not the head RID, to keep non-admins from attaching files to
174 ** threads which have since been taken over by another user (this
175 ** happens when an admin edits another user's post).
176 */
177 int attach_user_may(int rid, int eArtifactType){
178 if( g.perm.Admin ) return 1;
179 if( !login_is_individual() ) return 0;
180 switch(eArtifactType){
181 case CFTYPE_FORUM:
182 return g.perm.AttachForum && forumpost_is_owner(rid, 0);
183 case CFTYPE_WIKI:
184 return g.perm.ApndWiki && g.perm.Attach;
185 case CFTYPE_TICKET:
186 return g.perm.ApndTkt && g.perm.Attach;
187 case CFTYPE_EVENT:
188 return g.perm.Write && g.perm.ApndWiki && g.perm.Attach;
189 default:
190 return 0;
191 }
192 }
193
194 /*
195 ** Emits a single-button FORM which invokes
196 ** /attachadd with target=$zTarget.
197 */
198 void attach_render_attachadd_button(const char *zTarget){
199 /* This could be changed from POST to GET, and arguably should so
200 ** that the target=X part becomes part of the resulting URL. */
201 @ <form method="post" action="%R/attachadd">\
202 @ <input type="hidden" name="target" value="%T(zTarget)">\
203 @ <input type="submit" value="Attach...">
204 @ </form>\
205 }
206
207 /*
208 ** WEBPAGE: attachlist
209 ** List attachments.
@@ -69,20 +216,21 @@
216 ** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
217 **
218 ** If none are given, all attachments are listed. If one is given, only
219 ** attachments for the designated technote, ticket or wiki page are shown.
220 **
221 ** HASH may be just a prefix of the relevant forum post, technical
222 ** note, or ticket artifact hash, in which case all attachments of all
223 ** technical notes or tickets with the prefix will be listed. Forum
224 ** posts, on the other hand, require a unique hash or hash prefix.
225 */
226 void attachlist_page(void){
227 const char *zPage = P("page");
228 const char *zTkt = P("tkt");
229 const char *zTechNote = P("technote");
230 const char *zForumPost = P("forumpost");
231 char *zLink = 0;
232 Blob sql;
233 Stmt q;
234
235 if( zPage && zTkt ) zTkt = 0;
236 login_check_credentials();
@@ -102,32 +250,47 @@
250 if( fnid<=0 ){
251 webpage_error("Invalid forum post ID: %h", zForumPost);
252 }
253 blob_append_sql(&sql, " WHERE target="
254 "(SELECT uuid FROM blob WHERE rid=%d)", fnid);
255 zLink = mprintf("forum post <a href='%R/forumpost/%t'>%#h</a>",
256 zForumPost, hash_digits(0), zForumPost);
257 }else if( zPage ){
258 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
259 style_header("Attachments To Wiki page %h", zPage);
260 blob_append_sql(&sql, " WHERE target=%Q", zPage);
261 zLink = mprintf("wiki page <a href='%R/wiki?name=%t'>%h</a>",
262 zPage, zPage);
263 }else if( zTkt ){
264 if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
265 style_header("Attachments To Ticket %S", zTkt);
266 blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
267 zLink = mprintf("ticket <a href='%R/tktview?name=%t'>%#h</a>",
268 zTkt, hash_digits(0), zTkt);
269 }else if( zTechNote ){
270 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
271 style_header("Attachments To Tech Note %S", zTechNote);
272 blob_append_sql(&sql, " WHERE target GLOB '%q*'",
273 zTechNote);
274 zLink = mprintf("tech-note <a href='%R/technote?name=%t'>%#h</a>",
275 zTechNote, hash_digits(0), zTechNote);
276 }else{
277 if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
278 login_needed(g.anon.RdTkt || g.anon.RdWiki);
279 return;
280 }
281 style_header("All Attachments");
282 }
283 blob_append_sql(&sql, " ORDER BY mtime DESC");
284 db_prepare(&q, "%s", blob_sql_text(&sql));
285
286 if( zLink ){
287 @ <h2>Attachments for %s(zLink)</h2>
288 fossil_free(zLink);
289 zLink = 0;
290 }
291
292 @ <ol>
293 while( db_step(&q)==SQLITE_ROW ){
294 const char *zDate;
295 const char *zSrc;
296 const char *zTarget;
@@ -137,10 +300,11 @@
300 const char *zUuid;
301 const char *zDispUser;
302 const int attachid = db_column_int(&q, 7);
303 int type;
304 int i;
305 int bDeleted;
306 char *zUrlTail = 0;
307
308 if( moderation_pending(attachid)
309 && !moderation_user_could(attachid, 1, 0) ){
310 /* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
324 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
325 zFilename = &zFilename[i+1];
326 i = -1;
327 }
328 }
329 bDeleted = 0==zSrc || 0==zSrc[0];
330 type = attachment_target_type(zTarget, 1);
331 switch( type ){
332 case CFTYPE_TICKET:
333 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
334 break;
335 case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
341 case CFTYPE_WIKI:
342 zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
343 break;
344 }
345 @ <li><p>
346 if( bDeleted ){
347 @ <s>\
348 }
349 @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>\
350 moderation_pending_www(attachid);
351 @ <br>\
352 @ <a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
353 @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]\
354 if( bDeleted ){
355 @ </s>
356 }
357 @ <br>
358 if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
359 if( zComment && zComment[0] ){
360 /* FIXME (2026-06-05): Honor the N-card (comment mimetype). %W
361 ** (historically used here) assumes fossil-wiki and the
362 ** fileformat.wiki doc has always claimed that it defaults to
363 ** text/plain. /ainfo assumes it is plain text. */
364 @ %h(zComment)<br>
365 }
366 if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
367 if( bDeleted ){
368 zSrc = "Deleted from";
369 }else {
370 zSrc = "Added to";
371 }
372 switch( type ){
@@ -362,41 +539,81 @@
539 Manifest *pManifest;
540
541 db_begin_transaction();
542 blob_init(&content, aContent, szContent);
543 pManifest = manifest_parse(&content, 0, 0);
544 addCompress = pManifest!=0;
545 manifest_destroy(pManifest);
546 blob_init(&content, aContent, szContent);
547 if( addCompress ){
548 blob_compress(&content, &content);
 
549 }
550 rid = content_put_ex(&content, 0, 0, 0, needModerator);
551 zUUID = rid_to_uuid(rid);
552 blob_zero(&manifest);
553 for(i=n=0; zName[i]; i++){
554 if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
555 }
556 zName += n;
557 if( zName[0]==0 ) zName = "unknown";
558 blob_appendf(&manifest, "A %F%s %F %s\n",
559 zName, addCompress ? ".gz" : "", zTarget, zUUID);
560 if( zComment!=0 && zComment[0]!=0 ){
561 while( fossil_isspace(zComment[0]) ) zComment++;
562 n = strlen(zComment);
563 while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
564 if( n>0 ){
565 blob_appendf(&manifest, "C %#F\n", n, zComment);
566 }
567 }
568 zDate = date_in_standard_format("now");
569 blob_appendf(&manifest, "D %z\n", zDate);
570 blob_appendf(&manifest, "U %F\n", login_name());
571 md5sum_blob(&manifest, &cksum);
572 blob_appendf(&manifest, "Z %b\n", &cksum);
573 attach_put(&manifest, rid, needModerator);
574 assert( blob_is_reset(&manifest) );
575 db_end_transaction(0);
576 }
577
578 /*
579 ** Renders the "legacy" (static) /attachadd form. One of the first
580 ** four arguments must be non-NULL and the other three must be NULL.
581 ** zComment may be NULL, as may zFrom. See the call sites for more
582 ** context.
583 */
584 static void attach_render_legacy_form(const char *zForumPost,
585 const char *zTechNote,
586 const char *zTicket,
587 const char *zWikiPage,
588 const char *zComment,
589 const char *zFrom){
590 form_begin("enctype='multipart/form-data' id='attachadd-legacy-form'",
591 "%R/attachadd");
592 @ <div>\
593 @ File to Attach:
594 @ <input type="file" name="f" size="60"><br>
595 @ Description:<br>
596 @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
597 @ >%h(zComment)</textarea><br>
598 if( zForumPost ){
599 @ <input type="hidden" name="forumpost" value="%h(zForumPost)">\
600 }else if( zTicket ){
601 @ <input type="hidden" name="tkt" value="%h(zTicket)">\
602 }else if( zTechNote ){
603 @ <input type="hidden" name="technote" value="%h(zTechNote)">\
604 }else if( zWikiPage ){
605 @ <input type="hidden" name="page" value="%h(zWikiPage)">\
606 }
607 @ <input type="hidden" name="from" value="%h(zFrom)">\
608 @ <input type="submit" name="ok" value="Add Attachment">\
609 @ <input type="submit" name="cancel" value="Cancel">\
610 @ </div>
611 captcha_generate(0);
612 login_insert_csrf_secret();
613 @ </form>
614 }
615
616 /*
617 ** WEBPAGE: attachadd
618 ** Add a new attachment.
619 **
@@ -404,33 +621,54 @@
621 ** page=WIKIPAGE
622 ** technote=HASH
623 ** forumpost=HASH
624 ** from=URL
625 **
626 ** Adds a POSTed file attachment to the given target.
627 **
628 ** Or the "version 2" interface:
629 **
630 ** target=ATTACHMENT_TARGET
631 **
632 ** Behaves as documented for attachaddV2_page().
633 */
634 void attachadd_page(void){
635 const char *zPage;
636 const char *zForumPost;
637 const char *zTkt;
638 const char *zTechNote;
639 const char *aContent;
640 const char *zName;
641 const char *zComment;
 
642 const char *zTarget;
643 const char *zFrom; /* Origin page - redirect here after saving */
644 char *zTo = 0; /* Optionally redirect here after saving */
645 char *zTargetType = 0;
646 char *zExtraFree = 0;
647 int szContent;
648 int goodCaptcha = 1;
649 int szLimit = 0;
650
651 if( P("target")!=0 ){
652 attachaddV2_page();
653 return;
654 }
655 zPage = P("page");
656 zForumPost = P("forumpost");
657 zTkt = P("tkt");
658 zTechNote = P("technote");
659 zFrom = P("from");
660 aContent = P("f");
661 zName = PD("f:filename","unknown");
662 zComment = PD("comment", "");
663 szContent = atoi(PD("f:bytes","0"));
664
665 if( zFrom==0 ) zFrom = mprintf("%R/home");
666 if( P("cancel") ) cgi_redirect(zFrom);
667 if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
668 webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
669 " technote=X, or target=X");
670 }
671 login_check_credentials();
672 if( zForumPost ){
673 int fpid;
674 if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
683 "forum posts.");
684 }
685 zTarget = zExtraFree = rid_to_uuid(fpid);
686 zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
687 zTarget, zForumPost);
688 zTo = zFrom ? 0 : mprintf("%R/forumpost/%S", zTarget);
 
 
 
 
689 }else if( zPage ){
690 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
691 login_needed(g.anon.ApndWiki && g.anon.Attach);
692 return;
693 }
694 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
695 fossil_redirect_home();
696 }
697 zTarget = zPage;
698 zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%t\">%h</a>",
699 zPage, zPage);
700 zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
701 }else if ( zTechNote ){
702 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
703 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
704 return;
705 }
@@ -474,11 +709,11 @@
709 if( zTechNote==0) fossil_redirect_home();
710 }
711 zTarget = zTechNote;
712 zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
713 zTechNote, zTechNote);
714 zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget);
715 }else{
716 assert( zTkt );
717 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
718 login_needed(g.anon.ApndTkt && g.anon.Attach);
719 return;
@@ -487,21 +722,25 @@
722 zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
723 " WHERE tagname GLOB 'tkt-%q*'", zTkt);
724 if( zTkt==0 ) fossil_redirect_home();
725 }
726 zTarget = zTkt;
727 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%S\">%S</a>",
728 zTkt, zTkt);
729 zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget);
730 }
731 szLimit = db_get_int("attachment-size-limit", 0);
732 if( szContent<0 || (szLimit && szContent>szLimit) ){
733 /* This check must be done late so that zTargetType is set up. */
734 @ <p class="generalError">Attachment %h(zName) is too large.
735 @ <a href="%R/help/attachment-size-limit">Limit</a> is
736 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
737 /* Fall through and render form. */
738 }else if( P("ok")
739 && cgi_csrf_safe(2)
740 && szContent>0
741 && (goodCaptcha = captcha_is_correct(0)) ){
742 int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
743 (zTkt!=0 && ticket_need_moderation(0)) ||
744 (zPage!=0 && wiki_need_moderation(0));
745 attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
746 cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
750 style_header("Add Attachment");
751 if( !goodCaptcha ){
752 @ <p class="generalError">Error: Incorrect security code.</p>
753 }
754 @ <h2>Add Attachment To %s(zTargetType)</h2>
755 attach_render_legacy_form(zForumPost, zTechNote, zTkt, zPage,
756 zComment, zFrom);
757 builtin_fossil_js_bundle_or("attach", NULL);
758 style_finish_page();
759 fossil_free(zTargetType);
760 fossil_free(zExtraFree);
761 }
762
763 /*
764 ** WEBPAGE: attachadd_ajax_post hidden
765 **
766 ** Used by attachadd V2 to handle attachments via POST requests with:
767 **
768 ** target=ATTACHMENT_TARGET
769 ** file1..fileN=FILE_OBJECTS
770 ** dryrun=0|1
771 **
772 ** Each posted file in the set file1..fileN gets attached to the given
773 ** target, permissions permitting. If dryrun>0 then the change is
774 ** rolled back instead of committed. target=X must refer to a full
775 ** target ID, not a prefix.
776 **
777 ** Responds with JSON: an empty object on success and
778 ** {error:"message"} on error. The on-success response structure is
779 ** subject to amendment.
780 */
781 void attachadd_ajax_post(void){
782 const char *zTarget;
783 char *zExtraFree = 0;
784 int eTgtType = 0;
785 int bNeedsModeration = 0;
786 int goodCaptcha = 1;
787 int bRollback = 0; /* Roll back if true. */
788
789 if( ! ajax_route_bootstrap(0, 1) ){
790 return;
791 }else if( !(goodCaptcha = captcha_is_correct(0)) ){
792 goto ajax_err_403;
793 }else if( !ajax_check_csrf(2) ){
794 return;
795 }
796 db_begin_transaction();
797 zTarget = P("target");
798 eTgtType = attachment_target_type(zTarget, 1);
799 CX("{");
800 switch( eTgtType ){
801 default:
802 case 0:
803 ajax_route_error(400, "Invalid attachment target.");
804 db_rollback_transaction();
805 return;
806 case CFTYPE_FORUM:{
807 int fpid;
808 if( g.perm.AttachForum==0 ){
809 goto ajax_err_403;
810 }
811 fpid = forumpost_head_rid2(zTarget);
812 if( fpid<=0 ){
813 goto ajax_err_404;
814 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
815 ajax_route_error(403, "Only admins can attach files to "
816 "other users' forum posts.");
817 db_rollback_transaction();
818 return;
819 }
820 zTarget = zExtraFree = rid_to_uuid(fpid);
821 bNeedsModeration = forum_need_moderation();
822 break;
823 }
824 case CFTYPE_EVENT:{
825 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
826 goto ajax_err_403;
827 }
828 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
829 zTarget) ){
830 zTarget = zExtraFree =
831 db_text(0, "SELECT substr(tagname,7) FROM tag"
832 " WHERE tagname GLOB 'event-%q*'", zTarget);
833 if( zTarget==0){
834 goto ajax_err_404;
835 }
836 }
837 bNeedsModeration = 0;
838 break;
839 }
840 case CFTYPE_TICKET:{
841 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
842 goto ajax_err_403;
843 }
844 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
845 zTarget) ){
846 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
847 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
848 if( zTarget==0 ){
849 goto ajax_err_404;
850 }
851 }
852 bNeedsModeration = ticket_need_moderation(0);
853 break;
854 }
855 case CFTYPE_WIKI:{
856 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
857 goto ajax_err_403;
858 }
859 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
860 zTarget) ){
861 goto ajax_err_404;
862 }
863 bNeedsModeration = wiki_need_moderation(0);
864 break;
865 }
866 }
867
868 if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
869 CX("}");
870 if( atoi(PD("dryrun","0"))>0 ){
871 bRollback = 1;
872 }
873 }/*else error response was set up*/
874 fossil_free(zExtraFree);
875 db_end_transaction(bRollback);
876 return;
877 ajax_err_403:
878 if( db_transaction_nesting_depth()>0 ){
879 db_rollback_transaction();
880 }
881 ajax_route_error_forbidden();
882 return;
883 ajax_err_404:
884 assert( db_transaction_nesting_depth()>0 );
885 db_rollback_transaction();
886 ajax_route_error(404, "Target not found.");
887 return;
888 }
889
890 /*
891 ** A helper for AJAX-style routines which accept file attachments via
892 ** POST. zTarget must be a full attachment target. bNeedsModeration
893 ** must be true if the attachment requires moderation.
894 **
895 ** It is up to the caller to have validated all security measures
896 ** before calling this.
897 **
898 ** This looks for POSTed files names "file1".."fileN", stopping when
899 ** it finds no entry. Returns the number of entries attached to the
900 ** target or a negative value on error (in which case the current db
901 ** transaction will be in a rollback state).
902 **
903 ** The only errors are currently attachment size limit violations:
904 ** attachments must have a non-0 size and if the attachment-size-limit
905 ** setting is >0 then each file's size must be <= that.
906 **
907 ** If this returns a negative value, it will have populated an error
908 ** response using ajax_route_error(). On success it produces no
909 ** output.
910 **
911 ** ACHTUNG: if zTarget is a forum post, it "really should" be the ID
912 ** of the first version of that post, as that's where attachments are
913 ** intended to be applied so that they can be found and removed
914 ** consistently. Potential TODO is have this function do that if
915 ** attachment_target_type(zTarget,1)!=0 but it would (for current
916 ** uses) require duplicating work already done in the callers.
917 */
918 int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
919 int i;
920 int rc = 0;
921 int n = 0;
922 int szLimit; /* attachment-max-size setting */
923 char aKeyPrefix[20]; /* Buffer for key "file%d" */
924 char aKeySize[30]; /* Buffer for key "file%d:bytes" */
925 char aKeyName[30]; /* Buffer for key "file%d:filename" */
926 char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
927
928 db_begin_transaction();
929 szLimit = db_get_int("attachment-size-limit", 0);
930
931 for(i = 1; ; ++i, ++n){
932 /* Look for P("fileN"), where N=1..n */
933 const char *zContent;
934 const char *zFilename;
935 int szContent;
936 sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
937 zContent = P(aKeyPrefix);
938 if( !zContent ){
939 /* End of the list. */
940 break;
941 }
942 sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
943 aKeyPrefix);
944 szContent = atoi(PD(aKeySize,"-1"));
945 if( szContent<=0 ){
946 rc = -ajax_route_error(400,"Invalid file size: %d", szContent);
947 break;
948 }else if( szLimit>0 && szContent>szLimit ){
949 rc = -ajax_route_error(413, "File size limit is %d bytes.", szLimit);
950 break;
951 }else{
952 sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
953 aKeyPrefix);
954 sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
955 aKeyPrefix);
956 if( 0==(zFilename=P(aKeyName)) ){
957 rc = -ajax_route_error(400, "Missing filename.");
958 break;
959 }
960 attach_commit(zFilename, zTarget, zContent, szContent,
961 bNeedsModeration, P(aKeyDesc));
962 }
963 }
964 if( rc<0 ){
965 db_rollback_transaction();
966 return rc;
967 }else{
968 db_commit_transaction();
969 return n;
970 }
971 }
972
973 /*
974 ** Proxy for /attachadd?target=X
975 **
976 ** Lists attachments for, and can add them to, a target artifact.
977 **
978 ** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
979 ** from=ORIGINATING_URL
980 **
981 ** Works like /attachadd but uses a JS-based interactive attachment
982 ** selector.
983 **
984 ** from=X tells it where to redirect to when it's done.
985 **
986 ** This page requires a post-2018-ish JS-capable browser.
987 */
988 void attachaddV2_page(void){
989 const char *zFrom = P("from");
990 const char *zTarget = P("target");
991 char *zTo = 0;
992 char *zTargetType = 0;
993 char *zExtraFree = 0;
994 int eTgtType = 0;
995 int goodCaptcha = 1;
996 char const * noJsArgs[] = {0,0,0,0}; /* Args for noscript form */
997
998 if( P("cancel") ) cgi_redirect(zFrom);
999 if( 0==zTarget ){
1000 webpage_error("Requires target=X");
1001 }
1002 login_check_credentials();
1003 eTgtType = attachment_target_type(zTarget, 1);
1004 switch( eTgtType ){
1005 default:
1006 case 0:
1007 webpage_error("Cannot resolve target=%h.", zTarget);
1008 break;
1009 case CFTYPE_FORUM:{
1010 int fpid;
1011 if( g.perm.AttachForum==0 ){
1012 login_needed(g.anon.AttachForum);
1013 return;
1014 }
1015 fpid = forumpost_head_rid2(zTarget);
1016 if( fpid<=0 ){
1017 webpage_error("Invalid forum post ID: %h", zTarget);
1018 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
1019 webpage_error("Only admins can attach files to other users' "
1020 "forum posts.");
1021 }
1022 zTarget = zExtraFree = rid_to_uuid(fpid);
1023 noJsArgs[0] = zTarget;
1024 zTargetType = mprintf(
1025 "Forum post <a href=\"%R/forumpost/%S\">%.16h</a>",
1026 zTarget, zTarget
1027 );
1028 zTo = mprintf("%R/forumpost/%S", zTarget);
1029 break;
1030 }
1031 case CFTYPE_EVENT:{
1032 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1033 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
1034 return;
1035 }
1036 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1037 zTarget) ){
1038 zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag"
1039 " WHERE tagname GLOB 'event-%q*'",
1040 zTarget);
1041 if( zTarget==0) fossil_redirect_home();
1042 }
1043 zTo = zFrom ? 0 : mprintf("%R/technote?name=%T", zTarget);
1044 zTargetType = mprintf("Tech-note <a href=\"%R/technote/%s\">%S</a>",
1045 zTarget, zTarget);
1046 noJsArgs[1] = zTarget;
1047 break;
1048 }
1049 case CFTYPE_TICKET:{
1050 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
1051 login_needed(g.anon.ApndTkt && g.anon.Attach);
1052 return;
1053 }
1054 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
1055 zTarget) ){
1056 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
1057 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
1058 if( zTarget==0 ) fossil_redirect_home();
1059 }
1060 zTo = zFrom ? 0 : mprintf("%R/tktview/%t", zTarget);
1061 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
1062 zTarget, zTarget);
1063 noJsArgs[2] = zTarget;
1064 break;
1065 }
1066 case CFTYPE_WIKI:{
1067 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1068 login_needed(g.anon.ApndWiki && g.anon.Attach);
1069 return;
1070 }
1071 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1072 zTarget) ){
1073 fossil_redirect_home();
1074 }
1075 zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
1076 zTargetType = mprintf(
1077 "Wiki page <a href=\"%R/wiki?name=%h\">%h</a>",
1078 zTarget, zTarget
1079 );
1080 noJsArgs[3] = zTarget;
1081 break;
1082 }
1083 }
1084
1085 db_begin_transaction();
1086
1087 style_set_current_feature("attach");
1088 style_header("Add Attachment");
1089 if( !goodCaptcha ){
1090 @ <p class="generalError">Error: Incorrect security code.</p>
1091 }
1092 @ <h2>Attachments for %s(zTargetType)</h2>
1093 attachment_list(zTarget, NULL,
1094 ATTACHLIST_SIZE | ATTACHLIST_HIDE_UNAPPROVED);
1095 attach_render_legacy_form(
1096 noJsArgs[0], noJsArgs[1], noJsArgs[2],
1097 noJsArgs[3], 0,
1098 zFrom ? zFrom : (zTo ? zTo : (zTo=mprintf("%R/home")))
1099 );
1100 @ <div id='attachadd-form-wrapper' class='hidden'>
1101 /* fossil.attach.js populates this DIV with the attachment widget,
1102 ** imports these hidden fields, and removes the legacy form. */
1103 @ <input type="hidden" name="target" value="%h(zTarget)">
1104 if( zFrom ){
1105 @ <input type="hidden" name="from" value="%h(zFrom)">
1106 }
1107 if( zTo ){
1108 @ <input type="hidden" name="to" value="%h(zTo)">
1109 }
 
1110 captcha_generate(0);
1111 login_insert_csrf_secret();
1112 @ </div>
1113 builtin_fossil_js_bundle_or("attach", NULL);
1114 db_end_transaction(0);
1115 style_finish_page();
1116 fossil_free(zTargetType);
1117 fossil_free(zExtraFree);
1118 fossil_free(zTo);
1119 }
1120
1121 /*
1122 ** WEBPAGE: ainfo
1123 ** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
1178 && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
1179 ){
1180 if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
1181 zTktUuid = zTarget;
1182 showDelMenu = g.perm.WrTkt;
1183 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1184 zTarget) ){
1185 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
1186 zWikiName = zTarget;
1187 showDelMenu = g.perm.WrWiki;
1188 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1189 zTarget) ){
1190 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
1191 zTNUuid = zTarget;
1192 showDelMenu = g.perm.Write && g.perm.WrWiki;
1193 }
1194 if( showDelMenu ){
@@ -632,11 +1212,13 @@
1212 Blob cksum;
1213 const char *zFile = zName;
1214
1215 if( !bUserIsOwner ){
1216 if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
1217 webpage_error(
1218 "Only admins can delete other users' attachments."
1219 );
1220 }
1221 }
1222 db_begin_transaction();
1223 blob_zero(&manifest);
1224 for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
1238 @ <p>The attachment below has been deleted.</p>
1239 fossil_free(zNewDate);
1240 }
1241
1242 if( P("del")
1243 && ((zForumPost && (bUserIsOwner || forumpost_may_close()))
1244 || (zTktUuid && g.perm.WrTkt)
1245 || (zWikiName && g.perm.WrWiki)
1246 || (zTNUuid && g.perm.Write && g.perm.WrWiki))
1247 ){
1248 form_begin(0, "%R/ainfo/%!S", zUuid);
1249 @ <p>Confirm you want to delete the attachment shown below.
1250 @ <input type="submit" name="confirm" value="Confirm">
1251 login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
1293 @ (%d(rid))
1294 }
1295 modPending = moderation_pending_www(rid);
1296 if( zForumPost ){
1297 @ <tr><th>Forum&nbsp;Post:</th>
1298 @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a>\
1299 @ </td></tr>
1300 }else if( zTktUuid ){
1301 @ <tr><th>Ticket:</th>
1302 @ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
1303 }else if( zTNUuid ){
1304 @ <tr><th>Tech Note:</th>
1305 @ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
1306 }else if( zWikiName ){
1307 @ <tr><th>Wiki&nbsp;Page:</th>
1308 @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a>\
1309 @ </td></tr>
1310 }
1311 @ <tr><th>Date:</th><td>
1312 hyperlink_to_date(zDate, "</td></tr>");
1313 @ <tr><th>User:</th><td>
1314 hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
1319 }
1320 @ <tr><th>Filename:</th><td>%h(zName)</td></tr>
1321 if( g.perm.Setup ){
1322 @ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
1323 }
1324 @ <tr><th valign="top">Description:</th>\
1325 /* FIXME (2026-06-05): Honor the N-card (comment mimetype). */
1326 @ <td valign="top">%h(zDesc)</td></tr>
1327 @ </table>
1328
1329 if( modPending && (isModerator || bUserIsOwner) ){
1330 @ <div class="section">Moderation</div>
1331 @ <blockquote>
1332 form_begin(0, "%R/ainfo/%s", zUuid);
1333 @ <label><input type="radio" name="modaction" value="delete">
1334 @ Delete this attachment</label><br>
1335 if( isModerator ){
1336 #if 0
1337 /* TODO/FIXME (2026-06-03): only allow approval of an attachment
1338 ** if its target has been approved. Without this, we can end up
1339 ** with stale attachments which refer to rejected targets. We
1340 ** need a type-specific RID/UUID here, which requires
1341 ** refactoring above to get it. */
1342 const int tgtid = 0;
1343 if( moderation_pending(tgtid) ){
1344 @ <label><input type="radio" name="modaction" \
1345 @ disabled value="approve">
1346 @ <span class='modpending'>Cannot approve:
1347 @ target is pending moderation</span>\
1348 @ </label><br>
1349 }else
1350 #else
1351 {
1352 @ <label><input type="radio" name="modaction" value="approve">
1353 @ Approve this attachment</label><br>
1354 }
1355 #endif
1356 }
1357 @ <input type="submit" value="Submit">
1358 login_insert_csrf_secret();
1359 @ </form>
1360 @ </blockquote>
@@ -767,11 +1371,12 @@
1371 const char *z;
1372 content_get(ridSrc, &attach);
1373 blob_to_utf8_no_bom(&attach, 0);
1374 z = blob_str(&attach);
1375 if( zLn ){
1376 output_text_with_line_numbers(z, blob_size(&attach),
1377 zName, zLn, 1);
1378 }else{
1379 @ <pre>
1380 @ %h(z)
1381 @ </pre>
1382 }
@@ -798,10 +1403,13 @@
1403 */
1404 #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
1405 #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
1406 #define ATTACHLIST_SIZE 0x04 /* add size */
1407 #define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
1408 #define ATTACHLIST_DETAILS_CLOSED 0x10 /* Wrap in a closed DETAILS element */
1409 #define ATTACHLIST_DETAILS_OPEN 0x20 /* Wrap in an open DETAILS element */
1410 #define ATTACHLIST_HIDE_EMPTY 0x40 /* Skip if size<1 */
1411 #endif
1412
1413 /*
1414 ** Output HTML to show a list of attachments.
1415 */
@@ -810,19 +1418,25 @@
1418 const char *zHeader, /* Header to display with attachments */
1419 const int flags /* ATTACHLIST_... flags */
1420 ){
1421 int cnt = 0;
1422 char szBuf[36] = {0}; /* scratchpad for attachment size value */
1423 const char *zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
1424 ? " target=\"_blank\"" : "";
1425 const int bUseDetail = flags &
1426 (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN);
1427 Stmt q;
1428
1429 db_prepare(&q,
1430 "SELECT datetime(mtime,toLocal()), a.filename, a.user,"
1431 " b1.uuid, a.src, a.target, a.attachid, b2.size\n"
1432 " FROM attachment a, blob b1, blob b2\n"
1433 " WHERE a.isLatest\n"
1434 " AND a.src IS NOT NULL\n"
1435 " AND a.target=%Q\n"
1436 " AND b1.rid=a.attachid\n"
1437 " AND b2.uuid=a.src\n"
1438 " ORDER BY mtime DESC",
1439 zTarget
1440 );
1441 while( db_step(&q)==SQLITE_ROW ){
1442 const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
1446 const char *zSrc = db_column_text(&q, 4);
1447 const char *zTarget = db_column_text(&q, 5);
1448 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
1449 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
1450 const int aid = db_column_int(&q, 6);
1451 const int sz = db_column_int(&q, 7);
1452 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
1453 && moderation_pending(aid)
1454 && !moderation_user_could(aid, 1, 0) ){
1455 continue;
1456 }
1457 if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){
1458 /* Deleted or phantom items. */
1459 continue;
1460 }
1461 if( cnt==0 ){
1462 if( bUseDetail ){
1463 @ <details class='attachlist'
1464 if( ATTACHLIST_DETAILS_OPEN & flags ){
1465 @ open
1466 }
1467 @ >
1468 }else{
1469 @ <section class='attachlist'>
1470 }
1471 if( flags & ATTACHLIST_HRULE_ABOVE ){
1472 @ <hr>
1473 }
1474 if( bUseDetail ){
1475 @ <summary>%s(zHeader)</summary>
1476 }else{
1477 @ %s(zHeader)
1478 }
1479 @ <ul>
1480 }
1481 cnt++;
1482 switch( attachment_target_type(zTarget, 1) ){
1483 case CFTYPE_TICKET: zTypeArg = "tkt"; break;
1484 case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
1485 case CFTYPE_EVENT: zTypeArg = "technote"; break;
1486 case CFTYPE_WIKI:
1487 default: zTypeArg = "page"; break;
1488 }
1489 @ <li>
1490 @ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
1491 if( flags & ATTACHLIST_SIZE ){
 
1492 sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
1493 }
1494 @ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
1495 @&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
1496 @ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
1499 moderation_pending_www(aid);
1500 @ </li>
1501 }
1502 if( cnt ){
1503 @ </ul>
1504 if( bUseDetail ){
1505 @ </details>
1506 }else{
1507 @ </section>
1508 }
1509 }
1510 db_finalize(&q);
1511 }
1512
1513 /*
@@ -1023,15 +1656,204 @@
1656 }
1657 for(i = 2; i < g.argc; ++i){
1658 const char *zPage = g.argv[i];
1659 db_bind_text(&q, ":tgtname", zPage);
1660 while(SQLITE_ROW == db_step(&q)){
1661 const char *zTime = db_column_text(&q, 0);
1662 const char *zSrc = db_column_text(&q, 1);
1663 const char *zTarget = db_column_text(&q, 2);
1664 const char *zName = db_column_text(&q, 3);
1665 printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
1666 }
1667 db_reset(&q);
1668 }
1669 db_finalize(&q);
1670 }
1671
1672 /*
1673 ** Renders the list of attachments for artifact pManifest as JSON to
1674 ** blob pOut. If pManifest->type is not one of (CFTYPE_TICKET,
1675 ** CFTYPE_FORUM, CFTYPE_EVENT, CFTYPE_WIKI) then it behaves as if the
1676 ** result set is empty.
1677 **
1678 ** If there are no matching attachments then its behavior depends on
1679 ** emptyPolicy:
1680 **
1681 ** <0 = emit a JSON NULL
1682 ** 0 = emit no output
1683 ** >0 = emit an empty JSON array
1684 **
1685 ** If bLatestOnly is true then only the most recent entry for a given
1686 ** attachment is emitted, else all versions are emitted in descending
1687 ** mtime order.
1688 **
1689 ** Returns the number of attachments.
1690 **
1691 ** Output format:
1692 **
1693 ** [{
1694 ** "uuid": attachment artifact hash,
1695 ** "src": hash of the attachment blob,
1696 ** "target": wiki page name or ticket/event ID,
1697 ** "filename": filename of attachment,
1698 ** "mtime": ISO-8601 timestamp UTC,
1699 ** "isLatest": true if this is the latest version of this file
1700 ** else false,
1701 ** }, ...once per attachment]
1702 **
1703 */
1704 int attachments_to_json(const Manifest *pManifest,
1705 Blob *pOut, int bLatestOnly,
1706 int emptyPolicy){
1707 int i = 0;
1708 Stmt q = empty_Stmt;
1709 char *zToFree = 0;
1710 const char *zTgt = 0;
1711 switch(pManifest->type){
1712 case CFTYPE_FORUM: zTgt = zToFree = rid_to_uuid(pManifest->rid);
1713 break;
1714 case CFTYPE_WIKI: zTgt = pManifest->zWikiTitle; break;
1715 case CFTYPE_EVENT: zTgt = pManifest->zEventId; break;
1716 case CFTYPE_TICKET: zTgt = pManifest->zTicketUuid; break;
1717 default:
1718 goto empty_result;
1719 }
1720 db_prepare(&q,
1721 "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1722 " b2.size, b1.uuid, a.user, a.comment\n"
1723 " FROM attachment a, blob b1, blob b2\n"
1724 " WHERE a.target=%Q\n"
1725 " AND a.src IS NOT NULL\n"
1726 " AND b1.rid=a.attachid\n"
1727 " AND b2.uuid=a.src\n"
1728 " AND (a.isLatest OR %d)\n"
1729 " ORDER BY a.target, a.isLatest DESC, a.mtime DESC\n",
1730 zTgt, !bLatestOnly
1731 );
1732 while(SQLITE_ROW == db_step(&q)){
1733 const char *zTime = db_column_text(&q, 0);
1734 const char *zSrc = db_column_text(&q, 1);
1735 const char *zTarget = db_column_text(&q, 2);
1736 const char *zName = db_column_text(&q, 3);
1737 const int isLatest = db_column_int(&q, 4);
1738 const int sz = db_column_int(&q, 5);
1739 const char *zUuid = db_column_text(&q, 6);
1740 const char *zUser = db_column_text(&q, 7);
1741 const char *zComment = db_column_text(&q, 8);
1742 if(!i++){
1743 blob_append_char(pOut, '[');
1744 }else{
1745 blob_append_char(pOut, ',');
1746 }
1747 blob_appendf(
1748 pOut,
1749 "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1750 "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1751 "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
1752 zUuid, zSrc, zTarget,
1753 zName, sz, zTime, isLatest ? "true" : "false",
1754 zUser
1755 );
1756 if( zComment && zComment[0] ){
1757 blob_appendf(pOut, "%!j", zComment);
1758 }else{
1759 blob_append_literal(pOut, "null");
1760 }
1761 blob_append_char(pOut, '}');
1762 }
1763 fossil_free(zToFree);
1764 db_finalize(&q);
1765 if(!i){
1766 empty_result:
1767 if( emptyPolicy>0 ){
1768 blob_append_literal(pOut, "[]");
1769 }else if( emptyPolicy<0 ){
1770 blob_append_literal(pOut, "null");
1771 }
1772 }else{
1773 blob_append_char(pOut, ']');
1774 }
1775 return i;
1776 }
1777
1778 /*
1779 ** COMMAND: test-attachment-target
1780 **
1781 ** Usage: %fossil test-attachment-target TARGET_ID...
1782 */
1783 void test_attachment_target_type_cmd(void){
1784 int i;
1785 verify_all_options();
1786 db_find_and_open_repository(0, 0);
1787 if( g.argc<3 ){
1788 usage("test-attachment-target TARGET_ID");
1789 return;
1790 }
1791 for( i = 2; i < g.argc; ++i ){
1792 const char *zTarget = g.argv[i];
1793 const int rid = attachment_target_rid(zTarget, 0);
1794 const int type = attachment_target_type(zTarget, 0);
1795 const char *zType = "<invalid>";
1796 switch(type){
1797 case CFTYPE_EVENT: zType = "technote"; break;
1798 case CFTYPE_FORUM: zType = "forumpost"; break;
1799 case CFTYPE_TICKET: zType = "ticket"; break;
1800 case CFTYPE_WIKI: zType = "wiki"; break;
1801 }
1802 fossil_print("%-20s = %-9s #%d %z\n",
1803 zTarget, zType, rid,
1804 rid>0 ? rid_to_uuid(rid) : 0);
1805 }
1806 }
1807
1808
1809 /*
1810 ** COMMAND: test-attachments-to-json
1811 **
1812 ** Usage: %fossil test-attachments-to-json TARGET_ID...
1813 **
1814 ** Options:
1815 ** --old List all versions of attachments. Default is to
1816 ** list only the latest.
1817 ** --full Require a full target ID, not a prefix.
1818 **
1819 ** Emits a JSON array of attachments for the given attachment targets.
1820 ** The given IDs must be wiki page names, ticket hashes, tech-note
1821 ** hashes, or forum post hashes. By default it accepts hash prefixes
1822 ** but does no detection of ambiguity or cross-type prefix collisions
1823 ** so may emit curious results if given short, colliding IDs.
1824 */
1825 void test_attachments_to_json_cmd(void){
1826 const int emptyPolicy = 1;
1827 const int bLatestOnly = find_option("old",0,0)==0;
1828 const int bFullId = find_option("full",0,0)!=0;
1829 int i;
1830
1831 verify_all_options();
1832 db_find_and_open_repository(0, 0);
1833 if( g.argc<3 ){
1834 usage("test-attachments-to-json TARGET_ID");
1835 return;
1836 }
1837 for( i = 2; i < g.argc; ++i ){
1838 const char *zTarget = g.argv[i];
1839 const int rid = attachment_target_rid(zTarget, bFullId);
1840 if( 0==rid ){
1841 fossil_print("** cannot resolve %s\n", zTarget);
1842 }else{
1843 Blob b = BLOB_INITIALIZER;
1844 Manifest *pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1845 assert( pManifest );
1846 attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1847 fossil_print("Attachments for %s: ", zTarget);
1848 if( b.nUsed ){
1849 char *zPretty = db_text(0,"SELECT json_pretty(%B)", &b);
1850 fossil_print("%s\n", zPretty);
1851 fossil_free(zPretty);
1852 }else{
1853 fossil_print("none\n");
1854 }
1855 blob_reset(&b);
1856 manifest_destroy(pManifest);
1857 }
1858 }
1859 }
1860
+20 -1
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668668
CX("editStateMarkers: {"
669669
"/*Symbolic markers to denote certain edit states.*/"
670670
"isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671671
CX("confirmerButtonTicks: 3 "
672672
"/*default fossil.confirmer tick count.*/,\n");
673
+ CX("attachmentSizeLimit: %d,\n",
674
+ db_get_int("attachment-size-limit",0));
673675
/* Inject certain info about the current skin... */
674676
CX("skin:{");
675677
/* can leak a local filesystem path:
676678
CX("name: %!j,", skin_in_use());*/
677679
CX("isDark: %s"
678680
"/*true if the current skin has the 'white-foreground' detail*/",
679681
skin_detail_boolean("white-foreground") ? "true" : "false");
680682
CX("}\n"/*fossil.config.skin*/);
681683
CX("};\n"/* fossil.config */);
684
+ if( forum_statuses()->n>1 ){
685
+ const ForumStatusList * fsl = forum_statuses();
686
+ int i;
687
+ CX("window.fossil.config.forumStatuses = [");
688
+ for(i = 0; i < fsl->n; ++i){
689
+ const ForumStatus *fs = &fsl->aStatus[i];
690
+ if(i) CX(",");
691
+ CX("{label:%!j, value:%!j}", fs->zLabel, fs->zValue);
692
+ }
693
+ CX("];\n");
694
+ }
695
+#define JBOOL(COND) ((COND) ? "true" : "false")
682696
CX("window.fossil.user = {");
683697
CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
684
- CX("isAdmin: %s", (g.perm.Admin || g.perm.Setup) ? "true" : "false");
698
+ CX("isAdmin: %s,", JBOOL(g.perm.Admin || g.perm.Setup));
699
+ CX("mayAttachForum: %s,", JBOOL(g.perm.AttachForum));
700
+ CX("enableDebug: %s,", JBOOL(g.perm.Debug || g.perm.Admin));
701
+ CX("isIndividual: %s", JBOOL(login_is_individual()));
685702
CX("};\n"/*fossil.user*/);
686703
CX("if(fossil.config.skin.isDark) "
687704
"document.body.classList.add('fossil-dark-style');\n");
688705
/*
689706
** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
699716
}
700717
/* The remaining window.fossil bootstrap code is not dependent on
701718
** C-runtime state... */
702719
builtin_request_js("fossil.bootstrap.js");
703720
}
721
+#undef JBOOL
704722
}
705723
706724
/*
707725
** Given the NAME part of fossil.NAME.js, this function checks whether
708726
** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
732750
** entries: all known deps of this one. Each
733751
** REQUIRES an EXPLICIT trailing \0, including
734752
** the final one! */
735753
} fjs[] = {
736754
/* This list ordering isn't strictly important. */
755
+ {"attach", 0, "dom\0"},
737756
{"confirmer", 0, 0},
738757
{"copybutton", 0, "dom\0"},
739758
{"diff", 0, "dom\0fetch\0storage\0"
740759
/* maintenance note: "diff" needs "storage" for storing the
741760
** sbs-sync-scroll toggle. */},
742761
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668 CX("editStateMarkers: {"
669 "/*Symbolic markers to denote certain edit states.*/"
670 "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671 CX("confirmerButtonTicks: 3 "
672 "/*default fossil.confirmer tick count.*/,\n");
 
 
673 /* Inject certain info about the current skin... */
674 CX("skin:{");
675 /* can leak a local filesystem path:
676 CX("name: %!j,", skin_in_use());*/
677 CX("isDark: %s"
678 "/*true if the current skin has the 'white-foreground' detail*/",
679 skin_detail_boolean("white-foreground") ? "true" : "false");
680 CX("}\n"/*fossil.config.skin*/);
681 CX("};\n"/* fossil.config */);
 
 
 
 
 
 
 
 
 
 
 
 
682 CX("window.fossil.user = {");
683 CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
684 CX("isAdmin: %s", (g.perm.Admin || g.perm.Setup) ? "true" : "false");
 
 
 
685 CX("};\n"/*fossil.user*/);
686 CX("if(fossil.config.skin.isDark) "
687 "document.body.classList.add('fossil-dark-style');\n");
688 /*
689 ** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
699 }
700 /* The remaining window.fossil bootstrap code is not dependent on
701 ** C-runtime state... */
702 builtin_request_js("fossil.bootstrap.js");
703 }
 
704 }
705
706 /*
707 ** Given the NAME part of fossil.NAME.js, this function checks whether
708 ** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
732 ** entries: all known deps of this one. Each
733 ** REQUIRES an EXPLICIT trailing \0, including
734 ** the final one! */
735 } fjs[] = {
736 /* This list ordering isn't strictly important. */
 
737 {"confirmer", 0, 0},
738 {"copybutton", 0, "dom\0"},
739 {"diff", 0, "dom\0fetch\0storage\0"
740 /* maintenance note: "diff" needs "storage" for storing the
741 ** sbs-sync-scroll toggle. */},
742
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668 CX("editStateMarkers: {"
669 "/*Symbolic markers to denote certain edit states.*/"
670 "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671 CX("confirmerButtonTicks: 3 "
672 "/*default fossil.confirmer tick count.*/,\n");
673 CX("attachmentSizeLimit: %d,\n",
674 db_get_int("attachment-size-limit",0));
675 /* Inject certain info about the current skin... */
676 CX("skin:{");
677 /* can leak a local filesystem path:
678 CX("name: %!j,", skin_in_use());*/
679 CX("isDark: %s"
680 "/*true if the current skin has the 'white-foreground' detail*/",
681 skin_detail_boolean("white-foreground") ? "true" : "false");
682 CX("}\n"/*fossil.config.skin*/);
683 CX("};\n"/* fossil.config */);
684 if( forum_statuses()->n>1 ){
685 const ForumStatusList * fsl = forum_statuses();
686 int i;
687 CX("window.fossil.config.forumStatuses = [");
688 for(i = 0; i < fsl->n; ++i){
689 const ForumStatus *fs = &fsl->aStatus[i];
690 if(i) CX(",");
691 CX("{label:%!j, value:%!j}", fs->zLabel, fs->zValue);
692 }
693 CX("];\n");
694 }
695 #define JBOOL(COND) ((COND) ? "true" : "false")
696 CX("window.fossil.user = {");
697 CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
698 CX("isAdmin: %s,", JBOOL(g.perm.Admin || g.perm.Setup));
699 CX("mayAttachForum: %s,", JBOOL(g.perm.AttachForum));
700 CX("enableDebug: %s,", JBOOL(g.perm.Debug || g.perm.Admin));
701 CX("isIndividual: %s", JBOOL(login_is_individual()));
702 CX("};\n"/*fossil.user*/);
703 CX("if(fossil.config.skin.isDark) "
704 "document.body.classList.add('fossil-dark-style');\n");
705 /*
706 ** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
716 }
717 /* The remaining window.fossil bootstrap code is not dependent on
718 ** C-runtime state... */
719 builtin_request_js("fossil.bootstrap.js");
720 }
721 #undef JBOOL
722 }
723
724 /*
725 ** Given the NAME part of fossil.NAME.js, this function checks whether
726 ** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
750 ** entries: all known deps of this one. Each
751 ** REQUIRES an EXPLICIT trailing \0, including
752 ** the final one! */
753 } fjs[] = {
754 /* This list ordering isn't strictly important. */
755 {"attach", 0, "dom\0"},
756 {"confirmer", 0, 0},
757 {"copybutton", 0, "dom\0"},
758 {"diff", 0, "dom\0fetch\0storage\0"
759 /* maintenance note: "diff" needs "storage" for storing the
760 ** sbs-sync-scroll toggle. */},
761
+3 -2
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
13351335
}
13361336
fossil_free(zErr);
13371337
}
13381338
}
13391339
if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340
- /* x-f-l-c (X-Fossil-Login-Card card transmitted via cookie
1341
- ** instead of in the sync payload. */
1340
+ /* x-f-l-c (X-Fossil-Login-Card) transmitted via cookie instead of
1341
+ ** in the sync payload. The format of this value is the same as a
1342
+ ** "login" card, as parsed by xfer.c:page_xfer(). */
13421343
rc |= 0x04;
13431344
g.syncInfo.zLoginCard = fossil_strdup(z);
13441345
g.syncInfo.fLoginCardMode |= 0x02;
13451346
cgi_delete_parameter("x-f-l-c");
13461347
}
13471348
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
1335 }
1336 fossil_free(zErr);
1337 }
1338 }
1339 if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340 /* x-f-l-c (X-Fossil-Login-Card card transmitted via cookie
1341 ** instead of in the sync payload. */
 
1342 rc |= 0x04;
1343 g.syncInfo.zLoginCard = fossil_strdup(z);
1344 g.syncInfo.fLoginCardMode |= 0x02;
1345 cgi_delete_parameter("x-f-l-c");
1346 }
1347
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
1335 }
1336 fossil_free(zErr);
1337 }
1338 }
1339 if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340 /* x-f-l-c (X-Fossil-Login-Card) transmitted via cookie instead of
1341 ** in the sync payload. The format of this value is the same as a
1342 ** "login" card, as parsed by xfer.c:page_xfer(). */
1343 rc |= 0x04;
1344 g.syncInfo.zLoginCard = fossil_strdup(z);
1345 g.syncInfo.fLoginCardMode |= 0x02;
1346 cgi_delete_parameter("x-f-l-c");
1347 }
1348
--- src/comformat.c
+++ src/comformat.c
@@ -33,10 +33,12 @@
3333
*/
3434
#define COMMENT_PRINT_TRIM_CRLF ((u32)0x00000002) /* Trim leading CR/LF. */
3535
#define COMMENT_PRINT_TRIM_SPACE ((u32)0x00000004) /* Trim leading/trailing. */
3636
#define COMMENT_PRINT_WORD_BREAK ((u32)0x00000008) /* Break lines on words. */
3737
#define COMMENT_PRINT_ORIG_BREAK ((u32)0x00000010) /* Break before original. */
38
+#define COMMENT_PRINT_SUMMARY ((u32)0x00000020)
39
+ /* Truncate after first blank line. */
3840
#endif
3941
4042
/********* Code copied from SQLite src/shell.c.in on 2024-09-30 **********/
4143
/* Lookup table to estimate the number of columns consumed by a Unicode
4244
** character.
4345
--- src/comformat.c
+++ src/comformat.c
@@ -33,10 +33,12 @@
33 */
34 #define COMMENT_PRINT_TRIM_CRLF ((u32)0x00000002) /* Trim leading CR/LF. */
35 #define COMMENT_PRINT_TRIM_SPACE ((u32)0x00000004) /* Trim leading/trailing. */
36 #define COMMENT_PRINT_WORD_BREAK ((u32)0x00000008) /* Break lines on words. */
37 #define COMMENT_PRINT_ORIG_BREAK ((u32)0x00000010) /* Break before original. */
 
 
38 #endif
39
40 /********* Code copied from SQLite src/shell.c.in on 2024-09-30 **********/
41 /* Lookup table to estimate the number of columns consumed by a Unicode
42 ** character.
43
--- src/comformat.c
+++ src/comformat.c
@@ -33,10 +33,12 @@
33 */
34 #define COMMENT_PRINT_TRIM_CRLF ((u32)0x00000002) /* Trim leading CR/LF. */
35 #define COMMENT_PRINT_TRIM_SPACE ((u32)0x00000004) /* Trim leading/trailing. */
36 #define COMMENT_PRINT_WORD_BREAK ((u32)0x00000008) /* Break lines on words. */
37 #define COMMENT_PRINT_ORIG_BREAK ((u32)0x00000010) /* Break before original. */
38 #define COMMENT_PRINT_SUMMARY ((u32)0x00000020)
39 /* Truncate after first blank line. */
40 #endif
41
42 /********* Code copied from SQLite src/shell.c.in on 2024-09-30 **********/
43 /* Lookup table to estimate the number of columns consumed by a Unicode
44 ** character.
45
+163 -1
--- src/default.css
+++ src/default.css
@@ -1505,10 +1505,13 @@
15051505
}
15061506
table.numbered-lines td.line-numbers > pre {
15071507
margin: 0.25em/*must match top PADDING of td.file-content
15081508
> pre > code*/ 0 0 0;
15091509
padding: 0;
1510
+ line-height: inherit;
1511
+ font-size: inherit;
1512
+ font-family: inherit;
15101513
}
15111514
table.numbered-lines td.line-numbers span {
15121515
display: inline-block;
15131516
margin: 0;
15141517
padding: 0;
@@ -1693,11 +1696,11 @@
16931696
-0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
16941697
0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
16951698
0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
16961699
L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
16971700
9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698
-style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1701
+style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
16991702
background-repeat: no-repeat;
17001703
background-position: center;
17011704
/* When not using a background image, this additional style works
17021705
reasonably well along with a ::before content of "?": */
17031706
/*border-width: 1px;
@@ -2007,10 +2010,169 @@
20072010
margin: 0;
20082011
}
20092012
div.helpPage blockquote {
20102013
margin-left: 0.2em;
20112014
}
2015
+
2016
+/* .attach* = styles for file attachments */
2017
+section.attachlist {}
2018
+details.attachlist > summary {
2019
+ cursor: pointer;
2020
+}
2021
+/* .Attacher is the top container element used by the JS Attacher
2022
+ class in fossil.attach.js. */
2023
+.Attacher {
2024
+ margin-bottom: 1em;
2025
+ display: flex;
2026
+ flex-direction: column;
2027
+ gap: 0.75em;
2028
+}
2029
+.Attacher.reverse {
2030
+ flex-direction: column-reverse;
2031
+}
2032
+.Attacher .attach-row {
2033
+ display: flex;
2034
+ flex-direction: column;
2035
+ gap: 0.5em;
2036
+ padding: 0.75em;
2037
+ border: 1px dashed #ccc;
2038
+ border-radius: 0.25em;
2039
+ background-color: #fafafa;
2040
+}
2041
+.Attacher .error {
2042
+ padding: 0.5em;
2043
+ background-color: #d32f2f;
2044
+ color: #fff;
2045
+}
2046
+.Attacher .error a {
2047
+ color: inherit;
2048
+ text-decoration: underline;
2049
+}
2050
+body.fossil-dark-style .Attacher .attach-row {
2051
+ background-color: initial;
2052
+}
2053
+.Attacher .attach-dropzone {
2054
+ padding: 1em;
2055
+ text-align: center;
2056
+ background: #ffffff;
2057
+ border: 1px solid #ddd;
2058
+ cursor: pointer;
2059
+ border-radius: 0.25em;
2060
+ transition: background-color 0.15s linear;
2061
+ display: flex;
2062
+ flex-direction: row;
2063
+ flex-wrap: nowrap;
2064
+}
2065
+body.fossil-dark-style .Attacher .attach-dropzone{
2066
+ background: initial;
2067
+}
2068
+.Attacher .attach-dropzone.populated {
2069
+ background-color: #f1f8e9;
2070
+ border-color: #8bc34a;
2071
+ border-style: solid;
2072
+ text-align: left;
2073
+}
2074
+body.fossil-dark-style .Attacher .attach-dropzone.populated{
2075
+ background-color: initial;
2076
+}
2077
+.Attacher .attach-dropzone.dragover {
2078
+ background-color: #e1f5fe;
2079
+ color: black;
2080
+ border-color: #03a9f4;
2081
+}
2082
+body.fossil-dark-style .Attacher .attach-dropzone.dragover{
2083
+ background-color: #e1f5fe;
2084
+ border-color: #03a9f4;
2085
+}
2086
+.Attacher .thumbnail {
2087
+ max-width: 10em;
2088
+ max-height: 10em;
2089
+ margin: 0 1em;
2090
+}
2091
+.Attacher .attach-row-info{
2092
+ font-family: monospace;
2093
+ flex-grow: 1;
2094
+ display: flex;
2095
+ flex-direction: column;
2096
+}
2097
+.Attacher .attach-filename {}
2098
+.Attacher .attach-size {/*size and mimetype*/}
2099
+.Attacher .attach-desc {
2100
+ max-width: initial;
2101
+ width: 100%;
2102
+ box-sizing: border-box;
2103
+ min-height: 4em;
2104
+ padding: 0.5em;
2105
+ font-family: inherit;
2106
+ resize: vertical;
2107
+}
2108
+.Attacher .attach-row-remove {
2109
+ align-self: center;
2110
+ padding: 0.25em 0.75em;
2111
+ margin-left: 1em;
2112
+ background-color: #d32f2f;
2113
+ color: #fff;
2114
+ border: none;
2115
+ border-radius: 0.25em;
2116
+ cursor: pointer;
2117
+ font-weight: bold;
2118
+}
2119
+.Attacher .attach-row-remove:hover {
2120
+ background-color: #b71c1c;
2121
+}
2122
+.Attacher .attach-controls {
2123
+ display: flex;
2124
+ flex-direction: row;
2125
+ gap: 1em;
2126
+}
2127
+.Attacher .attach-controls .attach-add-button {
2128
+ padding: 0.5em 1em;
2129
+ cursor: pointer;
2130
+ flex-grow: 2;
2131
+ line-height: initial/*work around an inherited alignment quirk*/;
2132
+}
2133
+
2134
+/* .animate-X and their associated @keyframes are used by various
2135
+ widgets to animate their comings and goings. */
2136
+.animate-entrance {
2137
+ animation: slideFadeIn 0.25s linear forwards;
2138
+ transform-origin: top;
2139
+ overflow: hidden /*prevent content bleeding during expansion*/;
2140
+}
2141
+.animate-exit {
2142
+ animation: slideFadeOut 0.25s linear forwards;
2143
+ transform-origin: top;
2144
+ overflow: hidden;
2145
+}
2146
+@keyframes slideFadeIn {
2147
+ 0% {
2148
+ opacity: 0;
2149
+ transform: translateY(-1em/*must match slideFadeOut*/);
2150
+ max-height: 0;
2151
+ }
2152
+ 100% {
2153
+ opacity: 1;
2154
+ transform: translateY(0);
2155
+ max-height: 100em /*a value safely larger than the widget*/;
2156
+ }
2157
+}
2158
+@keyframes slideFadeOut {
2159
+ 0% {
2160
+ opacity: 1;
2161
+ transform: translateY(0);
2162
+ max-height: 100em /*must match slideFadeIn*/;
2163
+ }
2164
+ 100% {
2165
+ opacity: 0;
2166
+ transform: translateY(-1em/*must match slideFadeIn*/);
2167
+ max-height: 0;
2168
+ padding-top: 0;
2169
+ padding-bottom: 0;
2170
+ margin-top: 0;
2171
+ margin-bottom: 0;
2172
+ }
2173
+}
20122174
20132175
/* Objects in the "desktoponly" class are invisible on mobile */
20142176
@media screen and (max-width: 600px) {
20152177
.desktoponly {
20162178
display: none;
20172179
--- src/default.css
+++ src/default.css
@@ -1505,10 +1505,13 @@
1505 }
1506 table.numbered-lines td.line-numbers > pre {
1507 margin: 0.25em/*must match top PADDING of td.file-content
1508 > pre > code*/ 0 0 0;
1509 padding: 0;
 
 
 
1510 }
1511 table.numbered-lines td.line-numbers span {
1512 display: inline-block;
1513 margin: 0;
1514 padding: 0;
@@ -1693,11 +1696,11 @@
1693 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1694 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1695 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1696 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1697 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1699 background-repeat: no-repeat;
1700 background-position: center;
1701 /* When not using a background image, this additional style works
1702 reasonably well along with a ::before content of "?": */
1703 /*border-width: 1px;
@@ -2007,10 +2010,169 @@
2007 margin: 0;
2008 }
2009 div.helpPage blockquote {
2010 margin-left: 0.2em;
2011 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2012
2013 /* Objects in the "desktoponly" class are invisible on mobile */
2014 @media screen and (max-width: 600px) {
2015 .desktoponly {
2016 display: none;
2017
--- src/default.css
+++ src/default.css
@@ -1505,10 +1505,13 @@
1505 }
1506 table.numbered-lines td.line-numbers > pre {
1507 margin: 0.25em/*must match top PADDING of td.file-content
1508 > pre > code*/ 0 0 0;
1509 padding: 0;
1510 line-height: inherit;
1511 font-size: inherit;
1512 font-family: inherit;
1513 }
1514 table.numbered-lines td.line-numbers span {
1515 display: inline-block;
1516 margin: 0;
1517 padding: 0;
@@ -1693,11 +1696,11 @@
1696 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1697 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1698 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1699 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1700 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1701 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1702 background-repeat: no-repeat;
1703 background-position: center;
1704 /* When not using a background image, this additional style works
1705 reasonably well along with a ::before content of "?": */
1706 /*border-width: 1px;
@@ -2007,10 +2010,169 @@
2010 margin: 0;
2011 }
2012 div.helpPage blockquote {
2013 margin-left: 0.2em;
2014 }
2015
2016 /* .attach* = styles for file attachments */
2017 section.attachlist {}
2018 details.attachlist > summary {
2019 cursor: pointer;
2020 }
2021 /* .Attacher is the top container element used by the JS Attacher
2022 class in fossil.attach.js. */
2023 .Attacher {
2024 margin-bottom: 1em;
2025 display: flex;
2026 flex-direction: column;
2027 gap: 0.75em;
2028 }
2029 .Attacher.reverse {
2030 flex-direction: column-reverse;
2031 }
2032 .Attacher .attach-row {
2033 display: flex;
2034 flex-direction: column;
2035 gap: 0.5em;
2036 padding: 0.75em;
2037 border: 1px dashed #ccc;
2038 border-radius: 0.25em;
2039 background-color: #fafafa;
2040 }
2041 .Attacher .error {
2042 padding: 0.5em;
2043 background-color: #d32f2f;
2044 color: #fff;
2045 }
2046 .Attacher .error a {
2047 color: inherit;
2048 text-decoration: underline;
2049 }
2050 body.fossil-dark-style .Attacher .attach-row {
2051 background-color: initial;
2052 }
2053 .Attacher .attach-dropzone {
2054 padding: 1em;
2055 text-align: center;
2056 background: #ffffff;
2057 border: 1px solid #ddd;
2058 cursor: pointer;
2059 border-radius: 0.25em;
2060 transition: background-color 0.15s linear;
2061 display: flex;
2062 flex-direction: row;
2063 flex-wrap: nowrap;
2064 }
2065 body.fossil-dark-style .Attacher .attach-dropzone{
2066 background: initial;
2067 }
2068 .Attacher .attach-dropzone.populated {
2069 background-color: #f1f8e9;
2070 border-color: #8bc34a;
2071 border-style: solid;
2072 text-align: left;
2073 }
2074 body.fossil-dark-style .Attacher .attach-dropzone.populated{
2075 background-color: initial;
2076 }
2077 .Attacher .attach-dropzone.dragover {
2078 background-color: #e1f5fe;
2079 color: black;
2080 border-color: #03a9f4;
2081 }
2082 body.fossil-dark-style .Attacher .attach-dropzone.dragover{
2083 background-color: #e1f5fe;
2084 border-color: #03a9f4;
2085 }
2086 .Attacher .thumbnail {
2087 max-width: 10em;
2088 max-height: 10em;
2089 margin: 0 1em;
2090 }
2091 .Attacher .attach-row-info{
2092 font-family: monospace;
2093 flex-grow: 1;
2094 display: flex;
2095 flex-direction: column;
2096 }
2097 .Attacher .attach-filename {}
2098 .Attacher .attach-size {/*size and mimetype*/}
2099 .Attacher .attach-desc {
2100 max-width: initial;
2101 width: 100%;
2102 box-sizing: border-box;
2103 min-height: 4em;
2104 padding: 0.5em;
2105 font-family: inherit;
2106 resize: vertical;
2107 }
2108 .Attacher .attach-row-remove {
2109 align-self: center;
2110 padding: 0.25em 0.75em;
2111 margin-left: 1em;
2112 background-color: #d32f2f;
2113 color: #fff;
2114 border: none;
2115 border-radius: 0.25em;
2116 cursor: pointer;
2117 font-weight: bold;
2118 }
2119 .Attacher .attach-row-remove:hover {
2120 background-color: #b71c1c;
2121 }
2122 .Attacher .attach-controls {
2123 display: flex;
2124 flex-direction: row;
2125 gap: 1em;
2126 }
2127 .Attacher .attach-controls .attach-add-button {
2128 padding: 0.5em 1em;
2129 cursor: pointer;
2130 flex-grow: 2;
2131 line-height: initial/*work around an inherited alignment quirk*/;
2132 }
2133
2134 /* .animate-X and their associated @keyframes are used by various
2135 widgets to animate their comings and goings. */
2136 .animate-entrance {
2137 animation: slideFadeIn 0.25s linear forwards;
2138 transform-origin: top;
2139 overflow: hidden /*prevent content bleeding during expansion*/;
2140 }
2141 .animate-exit {
2142 animation: slideFadeOut 0.25s linear forwards;
2143 transform-origin: top;
2144 overflow: hidden;
2145 }
2146 @keyframes slideFadeIn {
2147 0% {
2148 opacity: 0;
2149 transform: translateY(-1em/*must match slideFadeOut*/);
2150 max-height: 0;
2151 }
2152 100% {
2153 opacity: 1;
2154 transform: translateY(0);
2155 max-height: 100em /*a value safely larger than the widget*/;
2156 }
2157 }
2158 @keyframes slideFadeOut {
2159 0% {
2160 opacity: 1;
2161 transform: translateY(0);
2162 max-height: 100em /*must match slideFadeIn*/;
2163 }
2164 100% {
2165 opacity: 0;
2166 transform: translateY(-1em/*must match slideFadeIn*/);
2167 max-height: 0;
2168 padding-top: 0;
2169 padding-bottom: 0;
2170 margin-top: 0;
2171 margin-bottom: 0;
2172 }
2173 }
2174
2175 /* Objects in the "desktoponly" class are invisible on mobile */
2176 @media screen and (max-width: 600px) {
2177 .desktoponly {
2178 display: none;
2179
+26 -14
--- src/delta.c
+++ src/delta.c
@@ -186,20 +186,29 @@
186186
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
187187
-1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
188188
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
189189
-1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
190190
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
191
+
192
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
193
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
194
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
195
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
196
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
197
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
198
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
199
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
191200
};
192201
unsigned int v = 0;
193202
int c;
194203
unsigned char *z = (unsigned char*)*pz;
195
- unsigned char *zStart = z;
196
- while( (c = zValue[0x7f&*(z++)])>=0 ){
197
- v = (v<<6) + c;
204
+ unsigned char *zEnd = z + (*pLen);
205
+ while( z<zEnd && (c = zValue[*z])>=0 ){
206
+ v = (v<<6) + c;
207
+ z++;
198208
}
199
- z--;
200
- *pLen -= z - zStart;
209
+ *pLen -= (int)(z - (unsigned char*)*pz);
201210
*pz = (char*)z;
202211
return v;
203212
}
204213
205214
/*
@@ -537,11 +546,11 @@
537546
** needed.
538547
*/
539548
int delta_output_size(const char *zDelta, int lenDelta){
540549
int size;
541550
size = getInt(&zDelta, &lenDelta);
542
- if( *zDelta!='\n' ){
551
+ if( lenDelta<=0 || *zDelta!='\n' ){
543552
/* ERROR: size integer not terminated by "\n" */
544553
return -1;
545554
}
546555
return size;
547556
}
@@ -574,28 +583,30 @@
574583
int lenDelta, /* Length of the delta */
575584
char *zOut /* Write the output into this preallocated buffer */
576585
){
577586
sqlite3_uint64 limit;
578587
sqlite3_uint64 total = 0;
588
+
579589
#ifdef FOSSIL_ENABLE_DELTA_CKSUM_TEST
580590
char *zOrigOut = zOut;
581591
#endif
582592
583593
limit = getInt(&zDelta, &lenDelta);
584
- if( *zDelta!='\n' ){
594
+ if( lenDelta<=0 || *zDelta!='\n' ){
585595
/* ERROR: size integer not terminated by "\n" */
586596
return -1;
587597
}
588
- zDelta++; lenDelta--;
589
- while( *zDelta && lenDelta>0 ){
598
+ zDelta++; lenDelta--; /* Skip the \n */
599
+ while( lenDelta>0 && zDelta[0] ){
590600
unsigned int cnt, ofst;
591601
cnt = getInt(&zDelta, &lenDelta);
602
+ if( lenDelta<=0 ) return -1;
592603
switch( zDelta[0] ){
593604
case '@': {
594605
zDelta++; lenDelta--;
595606
ofst = getInt(&zDelta, &lenDelta);
596
- if( lenDelta>0 && zDelta[0]!=',' ){
607
+ if( lenDelta<=0 || zDelta[0]!=',' ){
597608
/* ERROR: copy command not terminated by ',' */
598609
return -1;
599610
}
600611
zDelta++; lenDelta--;
601612
DEBUG1( printf("COPY %d from %d\n", cnt, ofst); )
@@ -618,11 +629,11 @@
618629
if( total>limit ){
619630
/* ERROR: insert command gives an output larger than predicted */
620631
return -1;
621632
}
622633
DEBUG1( printf("INSERT %d\n", cnt); )
623
- if( (int)cnt>lenDelta ){
634
+ if( (i64)cnt>(i64)lenDelta ){
624635
/* ERROR: insert count exceeds size of delta */
625636
return -1;
626637
}
627638
memcpy(zOut, zDelta, cnt);
628639
zOut += cnt;
@@ -668,23 +679,24 @@
668679
){
669680
unsigned int nInsert = 0;
670681
unsigned int nCopy = 0;
671682
672683
(void)getInt(&zDelta, &lenDelta);
673
- if( *zDelta!='\n' ){
684
+ if( lenDelta<=0 || *zDelta!='\n' ){
674685
/* ERROR: size integer not terminated by "\n" */
675686
return -1;
676687
}
677688
zDelta++; lenDelta--;
678689
while( *zDelta && lenDelta>0 ){
679690
unsigned int cnt;
680691
cnt = getInt(&zDelta, &lenDelta);
692
+ if( lenDelta<=0 ) break;
681693
switch( zDelta[0] ){
682694
case '@': {
683695
zDelta++; lenDelta--;
684696
(void)getInt(&zDelta, &lenDelta);
685
- if( lenDelta>0 && zDelta[0]!=',' ){
697
+ if( lenDelta<=0 || zDelta[0]!=',' ){
686698
/* ERROR: copy command not terminated by ',' */
687699
return -1;
688700
}
689701
zDelta++; lenDelta--;
690702
nCopy += cnt;
@@ -691,11 +703,11 @@
691703
break;
692704
}
693705
case ':': {
694706
zDelta++; lenDelta--;
695707
nInsert += cnt;
696
- if( (int)cnt>lenDelta ){
708
+ if( (i64)cnt>(i64)lenDelta ){
697709
/* ERROR: insert count exceeds size of delta */
698710
return -1;
699711
}
700712
zDelta += cnt;
701713
lenDelta -= cnt;
702714
--- src/delta.c
+++ src/delta.c
@@ -186,20 +186,29 @@
186 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
187 -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
188 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
189 -1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
190 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
 
 
 
 
 
 
 
 
 
191 };
192 unsigned int v = 0;
193 int c;
194 unsigned char *z = (unsigned char*)*pz;
195 unsigned char *zStart = z;
196 while( (c = zValue[0x7f&*(z++)])>=0 ){
197 v = (v<<6) + c;
 
198 }
199 z--;
200 *pLen -= z - zStart;
201 *pz = (char*)z;
202 return v;
203 }
204
205 /*
@@ -537,11 +546,11 @@
537 ** needed.
538 */
539 int delta_output_size(const char *zDelta, int lenDelta){
540 int size;
541 size = getInt(&zDelta, &lenDelta);
542 if( *zDelta!='\n' ){
543 /* ERROR: size integer not terminated by "\n" */
544 return -1;
545 }
546 return size;
547 }
@@ -574,28 +583,30 @@
574 int lenDelta, /* Length of the delta */
575 char *zOut /* Write the output into this preallocated buffer */
576 ){
577 sqlite3_uint64 limit;
578 sqlite3_uint64 total = 0;
 
579 #ifdef FOSSIL_ENABLE_DELTA_CKSUM_TEST
580 char *zOrigOut = zOut;
581 #endif
582
583 limit = getInt(&zDelta, &lenDelta);
584 if( *zDelta!='\n' ){
585 /* ERROR: size integer not terminated by "\n" */
586 return -1;
587 }
588 zDelta++; lenDelta--;
589 while( *zDelta && lenDelta>0 ){
590 unsigned int cnt, ofst;
591 cnt = getInt(&zDelta, &lenDelta);
 
592 switch( zDelta[0] ){
593 case '@': {
594 zDelta++; lenDelta--;
595 ofst = getInt(&zDelta, &lenDelta);
596 if( lenDelta>0 && zDelta[0]!=',' ){
597 /* ERROR: copy command not terminated by ',' */
598 return -1;
599 }
600 zDelta++; lenDelta--;
601 DEBUG1( printf("COPY %d from %d\n", cnt, ofst); )
@@ -618,11 +629,11 @@
618 if( total>limit ){
619 /* ERROR: insert command gives an output larger than predicted */
620 return -1;
621 }
622 DEBUG1( printf("INSERT %d\n", cnt); )
623 if( (int)cnt>lenDelta ){
624 /* ERROR: insert count exceeds size of delta */
625 return -1;
626 }
627 memcpy(zOut, zDelta, cnt);
628 zOut += cnt;
@@ -668,23 +679,24 @@
668 ){
669 unsigned int nInsert = 0;
670 unsigned int nCopy = 0;
671
672 (void)getInt(&zDelta, &lenDelta);
673 if( *zDelta!='\n' ){
674 /* ERROR: size integer not terminated by "\n" */
675 return -1;
676 }
677 zDelta++; lenDelta--;
678 while( *zDelta && lenDelta>0 ){
679 unsigned int cnt;
680 cnt = getInt(&zDelta, &lenDelta);
 
681 switch( zDelta[0] ){
682 case '@': {
683 zDelta++; lenDelta--;
684 (void)getInt(&zDelta, &lenDelta);
685 if( lenDelta>0 && zDelta[0]!=',' ){
686 /* ERROR: copy command not terminated by ',' */
687 return -1;
688 }
689 zDelta++; lenDelta--;
690 nCopy += cnt;
@@ -691,11 +703,11 @@
691 break;
692 }
693 case ':': {
694 zDelta++; lenDelta--;
695 nInsert += cnt;
696 if( (int)cnt>lenDelta ){
697 /* ERROR: insert count exceeds size of delta */
698 return -1;
699 }
700 zDelta += cnt;
701 lenDelta -= cnt;
702
--- src/delta.c
+++ src/delta.c
@@ -186,20 +186,29 @@
186 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
187 -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
188 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
189 -1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
190 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
191
192 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
193 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
194 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
195 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
196 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
197 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
198 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
199 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
200 };
201 unsigned int v = 0;
202 int c;
203 unsigned char *z = (unsigned char*)*pz;
204 unsigned char *zEnd = z + (*pLen);
205 while( z<zEnd && (c = zValue[*z])>=0 ){
206 v = (v<<6) + c;
207 z++;
208 }
209 *pLen -= (int)(z - (unsigned char*)*pz);
 
210 *pz = (char*)z;
211 return v;
212 }
213
214 /*
@@ -537,11 +546,11 @@
546 ** needed.
547 */
548 int delta_output_size(const char *zDelta, int lenDelta){
549 int size;
550 size = getInt(&zDelta, &lenDelta);
551 if( lenDelta<=0 || *zDelta!='\n' ){
552 /* ERROR: size integer not terminated by "\n" */
553 return -1;
554 }
555 return size;
556 }
@@ -574,28 +583,30 @@
583 int lenDelta, /* Length of the delta */
584 char *zOut /* Write the output into this preallocated buffer */
585 ){
586 sqlite3_uint64 limit;
587 sqlite3_uint64 total = 0;
588
589 #ifdef FOSSIL_ENABLE_DELTA_CKSUM_TEST
590 char *zOrigOut = zOut;
591 #endif
592
593 limit = getInt(&zDelta, &lenDelta);
594 if( lenDelta<=0 || *zDelta!='\n' ){
595 /* ERROR: size integer not terminated by "\n" */
596 return -1;
597 }
598 zDelta++; lenDelta--; /* Skip the \n */
599 while( lenDelta>0 && zDelta[0] ){
600 unsigned int cnt, ofst;
601 cnt = getInt(&zDelta, &lenDelta);
602 if( lenDelta<=0 ) return -1;
603 switch( zDelta[0] ){
604 case '@': {
605 zDelta++; lenDelta--;
606 ofst = getInt(&zDelta, &lenDelta);
607 if( lenDelta<=0 || zDelta[0]!=',' ){
608 /* ERROR: copy command not terminated by ',' */
609 return -1;
610 }
611 zDelta++; lenDelta--;
612 DEBUG1( printf("COPY %d from %d\n", cnt, ofst); )
@@ -618,11 +629,11 @@
629 if( total>limit ){
630 /* ERROR: insert command gives an output larger than predicted */
631 return -1;
632 }
633 DEBUG1( printf("INSERT %d\n", cnt); )
634 if( (i64)cnt>(i64)lenDelta ){
635 /* ERROR: insert count exceeds size of delta */
636 return -1;
637 }
638 memcpy(zOut, zDelta, cnt);
639 zOut += cnt;
@@ -668,23 +679,24 @@
679 ){
680 unsigned int nInsert = 0;
681 unsigned int nCopy = 0;
682
683 (void)getInt(&zDelta, &lenDelta);
684 if( lenDelta<=0 || *zDelta!='\n' ){
685 /* ERROR: size integer not terminated by "\n" */
686 return -1;
687 }
688 zDelta++; lenDelta--;
689 while( *zDelta && lenDelta>0 ){
690 unsigned int cnt;
691 cnt = getInt(&zDelta, &lenDelta);
692 if( lenDelta<=0 ) break;
693 switch( zDelta[0] ){
694 case '@': {
695 zDelta++; lenDelta--;
696 (void)getInt(&zDelta, &lenDelta);
697 if( lenDelta<=0 || zDelta[0]!=',' ){
698 /* ERROR: copy command not terminated by ',' */
699 return -1;
700 }
701 zDelta++; lenDelta--;
702 nCopy += cnt;
@@ -691,11 +703,11 @@
703 break;
704 }
705 case ':': {
706 zDelta++; lenDelta--;
707 nInsert += cnt;
708 if( (i64)cnt>(i64)lenDelta ){
709 /* ERROR: insert count exceeds size of delta */
710 return -1;
711 }
712 zDelta += cnt;
713 lenDelta -= cnt;
714
+1 -1
--- src/diffcmd.c
+++ src/diffcmd.c
@@ -776,11 +776,11 @@
776776
777777
/*
778778
** Return true if the disk file is identical to the Blob. Return zero
779779
** if the files differ in any way.
780780
*/
781
-static int file_same_as_blob(Blob *blob, const char *zDiskFile){
781
+int file_same_as_blob(Blob *blob, const char *zDiskFile){
782782
Blob file;
783783
int rc = 0;
784784
if( blob_size(blob)!=file_size(zDiskFile, ExtFILE) ) return 0;
785785
blob_zero(&file);
786786
blob_read_from_file(&file, zDiskFile, ExtFILE);
787787
--- src/diffcmd.c
+++ src/diffcmd.c
@@ -776,11 +776,11 @@
776
777 /*
778 ** Return true if the disk file is identical to the Blob. Return zero
779 ** if the files differ in any way.
780 */
781 static int file_same_as_blob(Blob *blob, const char *zDiskFile){
782 Blob file;
783 int rc = 0;
784 if( blob_size(blob)!=file_size(zDiskFile, ExtFILE) ) return 0;
785 blob_zero(&file);
786 blob_read_from_file(&file, zDiskFile, ExtFILE);
787
--- src/diffcmd.c
+++ src/diffcmd.c
@@ -776,11 +776,11 @@
776
777 /*
778 ** Return true if the disk file is identical to the Blob. Return zero
779 ** if the files differ in any way.
780 */
781 int file_same_as_blob(Blob *blob, const char *zDiskFile){
782 Blob file;
783 int rc = 0;
784 if( blob_size(blob)!=file_size(zDiskFile, ExtFILE) ) return 0;
785 blob_zero(&file);
786 blob_read_from_file(&file, zDiskFile, ExtFILE);
787
+14 -7
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117117
style_header("No Such Tech-Note");
118118
@ Cannot locate a technical note called <b>%h(zId)</b>.
119119
style_finish_page();
120120
return;
121121
}
122
- zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
122
+ zUuid = rid_to_uuid(rid);
123
+ zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
124
+ " FROM tag"
125
+ " WHERE tagname GLOB 'event-%q*'",
126
+ zId);
123127
zVerbose = P("v");
124128
if( !zVerbose ){
125129
zVerbose = P("verbose");
126130
}
127131
if( !zVerbose ){
@@ -157,11 +161,11 @@
157161
style_header("%s", blob_str(&title));
158162
if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
159163
style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
160164
if( g.perm.Attach ){
161165
style_submenu_element("Attach",
162
- "%R/attachadd?technote=%!S&from=%R/technote/%!S", zId, zId);
166
+ "%R/attachadd?target=%s&from=%R/technote/%!S", zFullId, zId);
163167
}
164168
}
165169
zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
166170
style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
167171
if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
225229
}else{
226230
@ <pre>
227231
@ %h(blob_str(&fullbody))
228232
@ </pre>
229233
}
230
- zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
231
- " FROM tag"
232
- " WHERE tagname GLOB 'event-%q*'",
233
- zId);
234
- attachment_list(zFullId, "<h2>Attachments:</h2>", 1);
234
+ {
235
+ char * z = mprintf(
236
+ "<h2><a href='%R/attachlist?technote=%t'>Attachments</a>:</h2>",
237
+ zFullId
238
+ );
239
+ attachment_list(zFullId, z, 1);
240
+ fossil_free(z);
241
+ }
235242
document_emit_js();
236243
style_finish_page();
237244
manifest_destroy(pTNote);
238245
}
239246
240247
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117 style_header("No Such Tech-Note");
118 @ Cannot locate a technical note called <b>%h(zId)</b>.
119 style_finish_page();
120 return;
121 }
122 zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
 
 
 
 
123 zVerbose = P("v");
124 if( !zVerbose ){
125 zVerbose = P("verbose");
126 }
127 if( !zVerbose ){
@@ -157,11 +161,11 @@
157 style_header("%s", blob_str(&title));
158 if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
159 style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
160 if( g.perm.Attach ){
161 style_submenu_element("Attach",
162 "%R/attachadd?technote=%!S&from=%R/technote/%!S", zId, zId);
163 }
164 }
165 zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
166 style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
167 if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
225 }else{
226 @ <pre>
227 @ %h(blob_str(&fullbody))
228 @ </pre>
229 }
230 zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
231 " FROM tag"
232 " WHERE tagname GLOB 'event-%q*'",
233 zId);
234 attachment_list(zFullId, "<h2>Attachments:</h2>", 1);
 
 
 
235 document_emit_js();
236 style_finish_page();
237 manifest_destroy(pTNote);
238 }
239
240
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117 style_header("No Such Tech-Note");
118 @ Cannot locate a technical note called <b>%h(zId)</b>.
119 style_finish_page();
120 return;
121 }
122 zUuid = rid_to_uuid(rid);
123 zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
124 " FROM tag"
125 " WHERE tagname GLOB 'event-%q*'",
126 zId);
127 zVerbose = P("v");
128 if( !zVerbose ){
129 zVerbose = P("verbose");
130 }
131 if( !zVerbose ){
@@ -157,11 +161,11 @@
161 style_header("%s", blob_str(&title));
162 if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
163 style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
164 if( g.perm.Attach ){
165 style_submenu_element("Attach",
166 "%R/attachadd?target=%s&from=%R/technote/%!S", zFullId, zId);
167 }
168 }
169 zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
170 style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
171 if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
229 }else{
230 @ <pre>
231 @ %h(blob_str(&fullbody))
232 @ </pre>
233 }
234 {
235 char * z = mprintf(
236 "<h2><a href='%R/attachlist?technote=%t'>Attachments</a>:</h2>",
237 zFullId
238 );
239 attachment_list(zFullId, z, 1);
240 fossil_free(z);
241 }
242 document_emit_js();
243 style_finish_page();
244 manifest_destroy(pTNote);
245 }
246
247
+542 -81
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
9696
9797
/*
9898
** Returns a high-level representation of the forum-statuses setting.
9999
** This is a singleton, cached across calls.
100100
*/
101
-static const ForumStatusList * forum_statuses(void){
101
+const ForumStatusList * forum_statuses(void){
102102
static ForumStatusList fses = {0,0};
103103
static int once = 0;
104104
while( !once ){
105105
++once;
106106
/* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151151
** found, the corresponding object is returned. If no match is found
152152
** then (A) if bFirst is false then 0 is returned, else (B) the first
153153
** entry in the list is returned, noting that the list may be empty,
154154
** in which case 0 is returned.
155155
*/
156
-const ForumStatus * forum_status_by_value(const char *z, int bFirst){
156
+static const ForumStatus * forum_status_by_value(
157
+ const char *z, int bFirst
158
+){
157159
const ForumStatusList * const fses = forum_statuses();
158160
const ForumStatus * fs0 = 0;
159161
unsigned int i;
160162
if( !fses->n ) return 0;
161163
for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,18 @@
227229
}
228230
229231
/*
230232
** Works like forumpost_head_rid() but expects zUuid to be an
231233
** unambiguous forum post name. It may be a hash prefix, so long as
232
-** it's unambiguous. Returns 0 if the name cannot be unambiguously
233
-** resolved as a forum post.
234
+** it's unambiguous. Returns the rid of the head post, -1 if the name
235
+** is ambiguous, and 0 if the name cannot be resolved as a forum post.
234236
*/
235237
int forumpost_head_rid2(const char *zUuid){
236238
const int fpid = symbolic_name_to_rid(zUuid, "f");
237239
return fpid>0
238240
? forumpost_head_rid(fpid)
239
- : 0;
241
+ : fpid;
240242
}
241243
242244
/*
243245
** Given a forum post RID and user name, returns true if zUserName
244246
** matches the event.(euser,user) field for a formpost entry with the
@@ -408,18 +410,17 @@
408410
** no tag is added. Similarly, it will only remove a tag from a post
409411
** which has its own tag, and will not remove an inherited one from a
410412
** parent post.
411413
**
412414
** If addTag is true and frid is already tagged, this is a
413
-** no-op. Likewise, if addTag is false and frid is not tagged
414
-** (not accounting for an inherited closed tag), this is a no-op.
415
-**
416
-** If bCheckIrt is true then the forum post IRT hierarchy is searched
417
-** for the tag, otherwise only the given RID is checked.
418
-**
419
-** Returns true if it actually creates a new tag, else false. Fails
420
-** fatally on error.
415
+** no-op. Likewise, if addTag is false and frid is not tagged (not
416
+** accounting for a tag inherited via an in-response-to post), this is
417
+** a no-op.
418
+**
419
+** Returns a positive value (a new tag.tagid value) if it actually
420
+** creates a new tag, else 0. On error it returns a negative alue
421
+** and g.zErrMsg "should" contain details.
421422
**
422423
** If it returns true then state from previously-loaded posts may be
423424
** invalidated if they refer to the amended post or a response to it.
424425
** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
425426
** stale.
@@ -438,11 +439,12 @@
438439
**
439440
** - The applied tag is propagating so so that "closed" tags can
440441
** account for how edits of posts are handled. This differs from
441442
** closure of a branch, where a non-propagating tag is used.
442443
*/
443
-static int forumpost_tag(int frid, const char *zTagName, int addTag,
444
+static int forumpost_tag(int frid, int addTag,
445
+ const char *zTagName,
444446
const char *zValue){
445447
Blob artifact = BLOB_INITIALIZER; /* Output artifact */
446448
Blob cksum = BLOB_INITIALIZER; /* Z-card */
447449
int iTagged; /* true if frid is already tagged */
448450
int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
461463
zValue = 0;
462464
}
463465
if( addTag && iTagged ){
464466
char *zOld = 0;
465467
int cmp;
466
- rid_has_tag2(iTagged, zTagName, &zOld);
468
+ rid_has_tag2(frid, zTagName, &zOld);
467469
cmp = fossil_strcmp(zOld, zValue);
468470
fossil_free(zOld);
469471
if( 0==cmp ){
470472
/* Same value - leave it as is. */
471473
db_end_transaction(0);
@@ -481,22 +483,58 @@
481483
md5sum_blob(&artifact, &cksum);
482484
blob_appendf(&artifact, "Z %b\n", &cksum);
483485
blob_reset(&cksum);
484486
trid = content_put_ex(&artifact, 0, 0, 0, 0);
485487
if( trid==0 ){
486
- fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
488
+ return -1;
487489
}
488490
if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
489
- fossil_fatal("%s", g.zErrMsg);
491
+ return -2;
490492
}
491493
assert( blob_is_reset(&artifact) );
492494
db_add_unsent(trid);
493495
admin_log("Tag forum post %S with %c%s",
494496
zUuid, addTag ? '*' : '-', zTagName);
495497
fossil_free(zUuid);
496498
db_end_transaction(0);
497
- return 1;
499
+ return trid;
500
+}
501
+
502
+/*
503
+** COMMAND: test-forumpost-tag
504
+**
505
+** Usage: %fossil test-forumpost-tag ?-cancel? THREADID TAGNAME TAGVAL
506
+**
507
+** A tester for forumpost_tag(). It always rolls back changes.
508
+*/
509
+void test_forumpost_tag_command(void){
510
+ int fpid;
511
+ int rc;
512
+ const char *zPost;
513
+ const char *zTag;
514
+ const char *zVal;
515
+ const int bAdd = find_option("cancel","",0)==0;
516
+
517
+ db_find_and_open_repository(0,0);
518
+ verify_all_options();
519
+ if( g.argc<5 ){
520
+ usage("forum-post-id tag-name value");
521
+ }
522
+ zPost = g.argv[2];
523
+ zTag = g.argv[3];
524
+ zVal = g.argv[4];
525
+
526
+ db_begin_transaction();
527
+ fpid = forumpost_head_rid2(zPost);
528
+ if( fpid<=0 ){
529
+ fossil_fatal("Cannot resolve post ID %s", zPost);
530
+ }
531
+ fossil_print("%s => %d => %z\n", zTag, fpid,
532
+ rid_to_uuid(fpid));
533
+ rc = forumpost_tag(fpid, bAdd, zTag, zVal);
534
+ fossil_print("tag fpid=%d taxgxref.tagid=%d\n", fpid, rc);
535
+ db_end_transaction(1);
498536
}
499537
500538
/*
501539
** Returns true if the forum-close-policy setting is true, else false,
502540
** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
894932
break;
895933
}
896934
}
897935
if( !sCurrent ) sCurrent = &fss->aStatus[0];
898936
assert( sCurrent );
899
- @ <span class='forum-status-selection'>
937
+ @ <fieldset class='forum-status-selection'>\
938
+ @ <legend>Status \
939
+ @ <span class='help-buttonlet initially-hidden'>\
940
+ @ Moderators and the post's owner may change \
941
+ @ the status of this thread unless it is still. \
942
+ @ pending moderation. See \
943
+ @ <a href='%R/help/forum-statuses' target='_new'>\
944
+ @ /help/forum-statuses</a></span>\
945
+ @ </legend>\
900946
if( forum_may_set_status(fp->fpid) ){
901947
@ <form method="post" action='%R/forumpost_status'>
902948
login_insert_csrf_secret();
903949
@ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
904950
@ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
917963
@ </form>
918964
/* Form is activated in fossil.page.forumpost.js */
919965
}else{
920966
@ <button disabled>Status: %h(sCurrent->zLabel)</button>
921967
}
922
- @ </span>
968
+ @ </fieldset>
923969
fossil_free(zCurrent);
924970
}
925971
}
926972
927973
/*
@@ -1045,17 +1091,26 @@
10451091
/*
10461092
** Renders the attachment list for the given forum post.
10471093
** Emits no output if there are no attachments.
10481094
*/
10491095
static void forum_render_attachment_list(const char *zUuid){
1050
- char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1051
- "Attachments:</a>", zUuid);
1052
- attachment_list(zUuid, zLbl,
1053
- ATTACHLIST_HRULE_ABOVE
1054
- | ATTACHLIST_SIZE
1055
- | ATTACHLIST_HIDE_UNAPPROVED);
1056
- fossil_free(zLbl);
1096
+#if 1
1097
+ attachment_list(zUuid, "&#x1f4ce; Attachments", 0
1098
+ | ATTACHLIST_SIZE
1099
+ | ATTACHLIST_HIDE_UNAPPROVED
1100
+ | ATTACHLIST_DETAILS_CLOSED
1101
+ | ATTACHLIST_HIDE_EMPTY);
1102
+#else
1103
+ char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
1104
+ "Attachments</a>:", zUuid);
1105
+ attachment_list(zUuid, zLbl,
1106
+ ATTACHLIST_HRULE_ABOVE
1107
+ | ATTACHLIST_SIZE
1108
+ | ATTACHLIST_HIDE_UNAPPROVED
1109
+ | ATTACHLIST_HIDE_EMPTY);
1110
+ fossil_free(zLbl);
1111
+#endif
10571112
}
10581113
10591114
/*
10601115
** Renders the attachment list for p or (if not NULL) pEditHead.
10611116
*/
@@ -1098,24 +1153,33 @@
10981153
10991154
/* Get the manifest for the post. Abort if not found (e.g. shunned). */
11001155
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
11011156
if( !pManifest ) return;
11021157
iClosed = forumpost_is_closed(pThread, p, 1);
1158
+ bPrivate = content_is_private(p->fpid);
1159
+ bSameUser = login_is_individual()
1160
+ && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
11031161
/* When not in raw mode, create the border around the post. */
11041162
if( !bRaw ){
11051163
/* Open the <div> enclosing the post. Set the class string to mark the post
11061164
** as selected and/or obsolete. */
11071165
iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1108
- @ <div id='forum%d(p->fpid)' class='forumTime\
1166
+ @ <div id='forum%d(p->fpid)' class='forumpost forumTime\
11091167
@ %s(bSelect ? " forumSel" : "")\
11101168
@ %s(iClosed ? " forumClosed" : "")\
11111169
@ %s(p->pEditTail ? " forumObs" : "")' \
11121170
if( iIndent && iIndentScale ){
1113
- @ style='margin-left:%d(iIndent*iIndentScale)ex;'>
1114
- }else{
1115
- @ >
1171
+ @ style='margin-left:%d(iIndent*iIndentScale)ex;' \
1172
+ }
1173
+ /* These data-X fields are used by the JS editor. */
1174
+ if( p->pIrt ){
1175
+ @ data-firt="%s(p->pIrt->zUuid)" \
1176
+ }
1177
+ if( p->pEditHead ){
1178
+ @ data-fedithead="%s(p->pEditHead->zUuid)" \
11161179
}
1180
+ @ data-fpid="%s(p->zUuid)">\
11171181
11181182
/* If this is the first post (or an edit thereof), emit the thread title. */
11191183
if( pManifest->zThreadTitle ){
11201184
@ <h1>%h(pManifest->zThreadTitle)</h1>
11211185
}
@@ -1196,32 +1260,33 @@
11961260
/* Provide a link to the raw source code. */
11971261
if( !bUnf ){
11981262
@ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
11991263
}
12001264
@ </h3>
1265
+
1266
+ if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){
1267
+ moderation_pending_www(p->fpid);
1268
+ }
12011269
}/*!bRaw*/
12021270
1203
- /* Check if this post is approved, also if it's by the current user. */
1204
- bPrivate = content_is_private(p->fpid);
1205
- bSameUser = login_is_individual()
1206
- && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1207
-
1208
- /* Render the post if the user is able to see it. */
1271
+ /* Check if this post is approved, also if it's by the current user.
1272
+ Render the post if the user is able to see it. */
12091273
if( bPrivate && !g.perm.ModForum && !bSameUser ){
12101274
@ <p><span class="modpending">Awaiting Moderator Approval</span></p>
12111275
}else{
12121276
if( bRaw || bUnf || p->pEditTail ){
12131277
zMimetype = "text/plain";
12141278
}else{
12151279
zMimetype = pManifest->zMimetype;
12161280
}
12171281
forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
1218
- forum_render_attachment_list2(p);
12191282
}
12201283
12211284
/* When not in raw mode, finish creating the border around the post. */
12221285
if( !bRaw ){
1286
+ int bBrBeforeAttach = 0; /* Layout kludge for Attach button */
1287
+ forum_render_attachment_list2(p);
12231288
/* If the user is able to write to the forum and if this post has not been
12241289
** edited, create a form with various interaction buttons. */
12251290
if( g.perm.WrForum && !p->pEditTail ){
12261291
@ <div class="forumpost-single-controls">\
12271292
@ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
12501315
@ <br><label><input type="checkbox" name="trust">
12511316
@ Trust user "%h(pManifest->zUser)" so that future posts by \
12521317
@ "%h(pManifest->zUser)" do not require moderation.
12531318
@ </label>
12541319
@ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
1320
+ bBrBeforeAttach = 1 /* slightly unmangle the layout */;
12551321
}
12561322
}else if( bSameUser ){
12571323
/* Allow users to delete (reject) their own pending posts. */
12581324
@ <input type="submit" name="reject" value="Delete">
12591325
}
@@ -1273,23 +1339,19 @@
12731339
@ %s(iClosed ? "action-reopen" : "action-close")'/>
12741340
/* ^^^ activated by fossil.page.forumpost.js */
12751341
}
12761342
@ </form>
12771343
}
1278
- if( g.perm.Admin ||
1279
- (login_is_individual()
1280
- && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1344
+ if( attach_user_may(p/*not pHead*/->fpid, CFTYPE_FORUM) ){
12811345
/* When an admin edits someone else's post, the admin
12821346
** effectively takes over ownership of it (and we currently
12831347
** have no way of passing it back). Because of this, we
12841348
** check the ownership of `p` instead of `pHead`. */
1285
- @ <form method="post" action="%R/attachadd">\
1286
- @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1287
- @ <input type="submit" value="Attach...">
1288
- login_insert_csrf_secret();
1289
- moderation_pending_www(p->fpid);
1290
- @ </form>
1349
+ if( bBrBeforeAttach ){
1350
+ @ <br>
1351
+ }
1352
+ attach_render_attachadd_button(pHead->zUuid);
12911353
}
12921354
}
12931355
@ </div>
12941356
}
12951357
if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
14751537
** to all forum-related pages. It does not include page-specific
14761538
** code (e.g. "forum.js").
14771539
*/
14781540
static void forum_emit_js(void){
14791541
builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1480
- NULL);
1542
+ "attach", "tabs", "storage",
1543
+ "popupwidget", NULL);
14811544
builtin_request_js("fossil.page.forumpost.js");
14821545
}
14831546
14841547
/*
14851548
** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
16351698
if( g.perm.WrTForum ) return 0;
16361699
if( g.perm.ModForum ) return 0;
16371700
return 1;
16381701
}
16391702
1640
-/*
1641
-** Return true if the string is white-space only.
1642
-*/
1643
-static int whitespace_only(const char *z){
1644
- if( z==0 ) return 1;
1645
- while( z[0] && fossil_isspace(z[0]) ){ z++; }
1646
- return z[0]==0;
1647
-}
1648
-
16491703
/* Flags for use with forum_post() */
16501704
#define FPOST_NO_ALERT 1 /* do not send any alerts */
1705
+#define FPOST_DRYRUN 2 /* do not save the artifact */
16511706
16521707
/*
16531708
** Return a flags value for use with the final argument to
16541709
** forum_post(), extracted from the CGI environment.
16551710
*/
@@ -1656,10 +1711,13 @@
16561711
static int forum_post_flags(void){
16571712
int iPostFlags = 0;
16581713
if( g.perm.Debug && P("fpsilent")!=0 ){
16591714
iPostFlags |= FPOST_NO_ALERT;
16601715
}
1716
+ if( P("dryrun")!=0 ){
1717
+ iPostFlags |= FPOST_DRYRUN;
1718
+ }
16611719
return iPostFlags;
16621720
}
16631721
16641722
/*
16651723
** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
16871745
if( !g.perm.Admin && (iEdit || iInReplyTo)
16881746
&& forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
16891747
forumpost_error_closed();
16901748
return 0;
16911749
}
1692
- if( iEdit==0 && whitespace_only(zContent) ){
1750
+ if( iEdit==0 && fossil_all_whitespace(zContent) ){
16931751
return 0;
16941752
}
16951753
if( iInReplyTo==0 && iEdit>0 ){
16961754
iBasis = iEdit;
16971755
iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
17121770
fossil_free(zG);
17131771
}
17141772
if( zTitle ){
17151773
blob_appendf(&x, "H %F\n", zTitle);
17161774
}
1717
- zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo);
1775
+ zI = rid_to_uuid(iInReplyTo);
17181776
if( zI ){
17191777
blob_appendf(&x, "I %s\n", zI);
17201778
fossil_free(zI);
17211779
}
1722
- if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1723
- blob_appendf(&x, "N %s\n", zMimetype);
1780
+ if( zMimetype!=0
1781
+ && zMimetype[0]!=0
1782
+ && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1783
+ blob_appendf(&x, "N %F\n", zMimetype);
17241784
}
17251785
if( iEdit>0 ){
17261786
char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
17271787
if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
17281788
blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
17501810
webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
17511811
}
17521812
webpage_assert( pPost->type==CFTYPE_FORUM );
17531813
manifest_destroy(pPost);
17541814
1755
- if( P("dryrun") ){
1815
+ if( (iFlags & FPOST_DRYRUN)!=0 ){
17561816
@ <div class='debug'>
17571817
@ This is the artifact that would have been generated:
17581818
@ <pre>%h(blob_str(&x))</pre>
17591819
@ </div>
17601820
blob_reset(&x);
@@ -1813,12 +1873,17 @@
18131873
int addTag, int validFpid){
18141874
if( !cgi_csrf_safe(2) ){
18151875
webpage_error("CSRF validation failed");
18161876
}else{
18171877
const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1818
- forumpost_tag(fpid, zTag, addTag, zVal);
1819
- cgi_redirectf("%R/forumpost/%S",P("fpid"));
1878
+ if( fpid>0 ){
1879
+ if( forumpost_tag(fpid, addTag, zTag, zVal) < 0 ){
1880
+ webpage_error("Tagging artifact failed: %s", g.zErrMsg);
1881
+ }else{
1882
+ cgi_redirectf("%R/forumpost/%S",P("fpid"));
1883
+ }
1884
+ }
18201885
}
18211886
}
18221887
18231888
/*
18241889
** WEBPAGE: forumpost_close hidden
@@ -1954,22 +2019,24 @@
19542019
}
19552020
}
19562021
19572022
/*
19582023
** If the user has AttachForum permissions, emit a notice that
1959
-** attachments may be added after saving. If p is not NULL,
1960
-** also emit its list of attachments.
2024
+** attachments may be added after saving. If p is not NULL, also emit
2025
+** its list of attachments. We only emit this for No-JS environments,
2026
+** as in JS environments the interactive forum editor includes
2027
+** attachment support.
19612028
*/
19622029
static void forum_render_attachment_notice(void){
19632030
if( g.perm.AttachForum ){
1964
- @ <div>You will be able to attach files to this post after saving
1965
- @ it.</div>
2031
+ @ <noscript><div>You will be able to attach files to this post
2032
+ @ after saving it.</div></noscript>
19662033
}
19672034
}
19682035
19692036
/*
1970
-** WEBPAGE: forume1
2037
+** WEBPAGE: forume1 hidden
19712038
**
19722039
** Start a new forum thread.
19732040
*/
19742041
void forumnew_page(void){
19752042
const char *zTitle = PDT("title","");
@@ -1983,36 +2050,45 @@
19832050
}
19842051
if( P("submit") && cgi_csrf_safe(2) ){
19852052
if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
19862053
forum_post_flags()) ) return;
19872054
}
1988
- if( P("preview") && !whitespace_only(zContent) ){
2055
+ if( P("preview") && !fossil_all_whitespace(zContent) ){
19892056
@ <h1>Preview:</h1>
19902057
forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
19912058
}
19922059
style_set_current_feature("forum");
19932060
style_header("New Forum Thread");
1994
- @ <form action="%R/forume1" method="POST">
2061
+
2062
+ @ <form action="%R/forume1" method="POST" \
2063
+ @ class="remove-if-replaced">
19952064
@ <h1>New Thread:</h1>
19962065
forum_from_line();
19972066
forum_post_widget(zTitle, zMimetype, zContent);
19982067
@ <input type="submit" name="preview" value="Preview">
1999
- if( P("preview") && !whitespace_only(zContent) ){
2068
+ if( P("preview") && !fossil_all_whitespace(zContent) ){
20002069
@ <input type="submit" name="submit" value="Submit">
20012070
}else{
20022071
@ <input type="submit" name="submit" value="Submit" disabled>
20032072
}
20042073
forum_render_debug_options();
20052074
login_insert_csrf_secret();
20062075
@ </form>
2076
+ /* When JS is disabled the block above will work. When it's
2077
+ enabled, the above will be removed and JS will render the editor
2078
+ form in the next element. */
2079
+ @ <div hidden id='forumnew-placeholder'>
2080
+ @ <input type='hidden' name='title' value='%h(zTitle)'>
2081
+ login_insert_csrf_secret();
2082
+ @ </div>
20072083
forum_render_attachment_notice();
20082084
forum_emit_js();
20092085
style_finish_page();
20102086
}
20112087
20122088
/*
2013
-** WEBPAGE: forume2
2089
+** WEBPAGE: forume2 hidden
20142090
**
20152091
** Edit an existing forum message.
20162092
** Query parameters:
20172093
**
20182094
** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2173,11 @@
20972173
style_set_current_feature("forum");
20982174
isDelete = P("nullout")!=0;
20992175
if( P("submit")
21002176
&& isCsrfSafe
21012177
&& (zContent = PDT("content",""))!=0
2102
- && (!whitespace_only(zContent) || isDelete)
2178
+ && (isDelete || !fossil_all_whitespace(zContent))
21032179
){
21042180
int done = 1;
21052181
const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
21062182
if( bReply ){
21072183
done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2251,11 @@
21752251
zDisplayName = display_name_from_login(pPost->zUser);
21762252
@ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
21772253
fossil_free(zDisplayName);
21782254
fossil_free(zDate);
21792255
forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2180
- if( bPreview && !whitespace_only(zContent) ){
2256
+ if( bPreview && !fossil_all_whitespace(zContent) ){
21812257
@ <h2>Preview:</h2>
21822258
forum_render(0, zMimetype,zContent, "forumEdit", 1);
21832259
}
21842260
@ <h2>Enter Reply:</h2>
21852261
@ <form action="%R/forume2" method="POST">
@@ -2190,11 +2266,11 @@
21902266
}
21912267
if( !isDelete ){
21922268
@ <input type="submit" name="preview" value="Preview">
21932269
}
21942270
@ <input type="submit" name="cancel" value="Cancel">
2195
- if( (bPreview && !whitespace_only(zContent)) || isDelete ){
2271
+ if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){
21962272
if( !iClosed || g.perm.Admin ) {
21972273
@ <input type="submit" name="submit" value="Submit">
21982274
}
21992275
}
22002276
forum_render_debug_options();
@@ -2201,11 +2277,13 @@
22012277
login_insert_csrf_secret();
22022278
@ </form>
22032279
if( !bReply ){
22042280
forum_render_attachment_list(rid_to_uuid(fpid));
22052281
}
2206
- forum_render_attachment_notice();
2282
+ if( !isDelete ){
2283
+ forum_render_attachment_notice();
2284
+ }
22072285
forum_emit_js();
22082286
style_finish_page();
22092287
}
22102288
22112289
/*
@@ -2214,21 +2292,21 @@
22142292
** to closed posts. If false, only administrators may do so. Note that
22152293
** this only affects the forum web UI, not post-closing tags which
22162294
** arrive via the command-line or from synchronization with a remote.
22172295
** This policy also determines whether moderators may delete forum
22182296
** attachments.
2219
-*/
2220
-/*
2297
+**
22212298
** SETTING: forum-title width=20 default=Forum
22222299
** This is the name or "title" of the Forum for this repository. The
22232300
** default is just "Forum". But in some setups, admins might want to
22242301
** change it to "Developer Forum" or "User Forum" or whatever other name
22252302
** seems more appropriate for the particular usage.
22262303
**
22272304
** SETTING: attachment-size-limit width=16
2228
-** The maximum number of bytes for an attachment. The default (or 0) is
2229
-** unlimited but a limit may be imposed by the web server or a proxy.
2305
+** The maximum number of bytes for an attachment to a wiki page,
2306
+** ticket, tech note, or forum post. The default (or 0) is unlimited
2307
+** but a limit may be imposed by the web server or a proxy.
22302308
**
22312309
** SETTING: forum-statuses width=40 block-text
22322310
** This JSON5-formatted value defines an array of objects describing
22332311
** the available statuses of forum posts. Each entry of the array must
22342312
** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2801,388 @@
27232801
** URL arg when the status selection list is activated. */
27242802
forum_emit_js();
27252803
}
27262804
style_finish_page();
27272805
}
2806
+
2807
+/*
2808
+** The AJAX counterpart of forum_post().
2809
+**
2810
+** Returns the new artifact's RID on success, 0 if no changes were
2811
+** necessary (e.g. an empty new post or dry-run mode), and a negative
2812
+** value on error. If it returns a negative value then it will have
2813
+** populated the ajax response state with an error object.
2814
+**
2815
+** zTitle must be NULL if iInReplyTo>0 and must be non-empty if
2816
+** iInReplyTo==0.
2817
+**
2818
+** The caller must have started a transaction and must roll it back if
2819
+** this call returns <=0, noting that only the negative-value case is
2820
+** an error.
2821
+**
2822
+** This function does some work to try to ensure that duplicate
2823
+** entries are not save (this can happen as a side effect of the forum
2824
+** post editor added in 2026-06). If the given post will not have been
2825
+** materially edited by these changes, they are not applied and the
2826
+** rid of the existing entry is used.
2827
+**
2828
+** Maintenance reminders:
2829
+**
2830
+** - iInReplyTo==0 && iEdit==0: new thread
2831
+** - iInReplyTo==0 && iEdit>0 : edit top post or response
2832
+** - iInReplyTo>0 && iEdit==0: new response
2833
+** - iInReplyTo>0 && iEdit>0 : edit response
2834
+*/
2835
+static int forum_post_ajax(
2836
+ const char *zTitle, /* Title. NULL for replies */
2837
+ int iInReplyTo, /* Post replying to. 0 for new threads */
2838
+ int iEdit, /* Post being edited, or zero for a new post */
2839
+ const char *zUser, /* Username. NULL means use login name */
2840
+ const char *zMimetype, /* Mimetype of content. */
2841
+ const char *zContent, /* Content */
2842
+ int iFlags /* FPOST_xyz flag values */
2843
+){
2844
+ char *zI;
2845
+ char *zG;
2846
+ char *zP = 0;
2847
+ int iBasis;
2848
+ Blob x = BLOB_INITIALIZER,
2849
+ cksum = BLOB_INITIALIZER,
2850
+ formatCheck = BLOB_INITIALIZER,
2851
+ errMsg = BLOB_INITIALIZER;
2852
+ Manifest *pPost = 0;
2853
+ int nContent = zContent ? (int)strlen(zContent) : 0;
2854
+ int rc = 0;
2855
+
2856
+ assert( db_transaction_nesting_depth()>0 );
2857
+ schema_forum();
2858
+ if( iEdit==0 && fossil_all_whitespace(zContent) ){
2859
+ return 0;
2860
+ }
2861
+ if( !g.perm.Admin && (iEdit || iInReplyTo)
2862
+ && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
2863
+ return -ajax_route_error(400, "Thread is closed.");
2864
+ }
2865
+ if( 0==iInReplyTo && fossil_all_whitespace(zTitle) ){
2866
+ return -ajax_route_error(400, "Empty title is not permitted.");
2867
+ }
2868
+
2869
+ if( zUser==0 ){
2870
+ if( login_is_nobody() ){
2871
+ zUser = "anonymous";
2872
+ }else{
2873
+ zUser = login_name();
2874
+ }
2875
+ }
2876
+ if( iEdit>0
2877
+ && !g.perm.Admin
2878
+ && !forumpost_is_owner(iEdit, zUser) ){
2879
+ return -ajax_route_error(
2880
+ 403, "Only admins may edit other peoples' posts."
2881
+ );
2882
+ }
2883
+ if( iInReplyTo==0 && iEdit>0 ){
2884
+ iBasis = iEdit;
2885
+ iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d",
2886
+ iEdit);
2887
+ }else{
2888
+ iBasis = iInReplyTo;
2889
+ /* TODO (2026-06-008) If (iInReplyTo>0 && iEdit>0), validate that
2890
+ ** iInReplyTo is connected to iEdit properly, else we risk
2891
+ ** reparenting the new edit and having unrepredictable downstream
2892
+ ** side effects. */
2893
+ }
2894
+
2895
+ if( 0!=zMimetype && 0==zMimetype[0] ){
2896
+ zMimetype = 0;
2897
+ }
2898
+
2899
+ if( 0!=zTitle && 0==zTitle[0] ) zTitle = 0;
2900
+ webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 );
2901
+
2902
+ if( iEdit>0 ){
2903
+ int cmp;
2904
+ pPost = manifest_get(iEdit, CFTYPE_FORUM, 0);
2905
+ if( pPost==0 ){
2906
+ rc = -ajax_route_error(404, "Missing edit artifact %d", iEdit);
2907
+ goto post_ajax_end;
2908
+ }
2909
+ /*
2910
+ ** If the old content matches the new then do not save a new copy.
2911
+ ** It's easy to get re-posts of unedited content via the forum
2912
+ ** editor, especially since the one added in 2026-06, where a
2913
+ ** post's status and attachments may be amended from the editor
2914
+ ** without modifying any of the post's content. In the legacy
2915
+ ** editor such "out-of-band" changes weren't possible and users
2916
+ ** have never made a practice of re-posting unedited content.
2917
+ **
2918
+ ** We compare the following fields to the original: user, mimetype,
2919
+ ** content, and (for root posts only) the title.
2920
+ */
2921
+ cmp = (0==pPost->zInReplyTo)
2922
+ ? fossil_strcmp(pPost->zThreadTitle, zTitle)
2923
+ : 0;
2924
+ if( 0==cmp ){
2925
+ cmp=fossil_strcmp(pPost->zWiki, zContent);
2926
+ if( 0==cmp ){
2927
+ cmp = fossil_strcmp(pPost->zUser, zUser);
2928
+ }
2929
+ if( 0==cmp
2930
+ && 0!=(cmp=fossil_strcmp(pPost->zMimetype, zMimetype)) ){
2931
+ /* Extra mimetype checks for a common condition seen elsewhere */
2932
+ if( (0==zMimetype
2933
+ && 0==fossil_strcmp(pPost->zMimetype, "text/x-fossil-wiki"))
2934
+ || (0==pPost->zMimetype
2935
+ && 0==fossil_strcmp(zMimetype, "text/x-fossil-wiki")) ){
2936
+ cmp = 0;
2937
+ }
2938
+ }
2939
+ if( 0==cmp ){
2940
+ rc = iEdit;
2941
+ goto post_ajax_end;
2942
+ }
2943
+ }
2944
+ zP = rid_to_uuid(iEdit);
2945
+ }
2946
+
2947
+ /* Write the new artifact */
2948
+ blob_init(&x, 0, 0);
2949
+ blob_appendf(&x, "D %z\n", date_in_standard_format("now"));
2950
+ zG = db_text(
2951
+ 0,
2952
+ "SELECT uuid FROM blob, forumpost"
2953
+ " WHERE blob.rid==forumpost.froot"
2954
+ " AND forumpost.fpid=%d",
2955
+ iBasis
2956
+ );
2957
+ if( zG ){
2958
+ blob_appendf(&x, "G %z\n", zG);
2959
+ }
2960
+ if( zTitle ){
2961
+ blob_appendf(&x, "H %F\n", zTitle);
2962
+ }
2963
+ if( iInReplyTo>0 ){
2964
+ zI = rid_to_uuid(iInReplyTo);
2965
+ if( 0==zI ){
2966
+ rc = -ajax_route_error(404, "Missing in-reply-to artifact %d",
2967
+ iInReplyTo);
2968
+ goto post_ajax_end;
2969
+ }
2970
+ blob_appendf(&x, "I %z\n", zI);
2971
+ }
2972
+ if( zMimetype!=0
2973
+ && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
2974
+ blob_appendf(&x, "N %F\n", zMimetype);
2975
+ }
2976
+ if( zP ){
2977
+ blob_appendf(&x, "P %s\n", zP);
2978
+ }
2979
+
2980
+ blob_appendf(&x, "U %F\n", zUser);
2981
+ blob_appendf(&x, "W %d\n%s\n", nContent, zContent);
2982
+ md5sum_blob(&x, &cksum);
2983
+ blob_appendf(&x, "Z %b\n", &cksum);
2984
+ blob_reset(&cksum);
2985
+
2986
+ /* Verify that the artifact we are creating is well-formed */
2987
+ blob_init(&formatCheck, 0, 0);
2988
+ blob_init(&errMsg, 0, 0);
2989
+ blob_copy(&formatCheck, &x);
2990
+ pPost = manifest_parse(&formatCheck, 0, &errMsg);
2991
+ if( pPost==0 ){
2992
+ ajax_route_error(500, "Malformed forum post artifact: %b", &errMsg);
2993
+ rc = -500;
2994
+ goto post_ajax_end;
2995
+ }
2996
+ webpage_assert( pPost->type==CFTYPE_FORUM );
2997
+
2998
+ if( (iFlags & FPOST_DRYRUN)!=0 ){
2999
+ rc = 0;
3000
+ }else{
3001
+ int nrid;
3002
+ db_begin_transaction();
3003
+ nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation());
3004
+ blob_reset(&x);
3005
+ if( (iFlags & FPOST_NO_ALERT)!=0 ){
3006
+ alert_unqueue('f', nrid);
3007
+ }
3008
+ rc = nrid;
3009
+ db_end_transaction(0);
3010
+ }
3011
+post_ajax_end:
3012
+ manifest_destroy(pPost);
3013
+ fossil_free(zP);
3014
+ blob_reset(&x);
3015
+ blob_reset(&cksum);
3016
+ blob_reset(&formatCheck);
3017
+ return rc;
3018
+}
3019
+/*
3020
+** WEBPAGE: forumajax_save hidden
3021
+**
3022
+** WIP
3023
+**
3024
+** Response JSON:
3025
+**
3026
+** { uuid: hash, ...tbd }
3027
+*/
3028
+void forum_ajax_save_page(void){
3029
+ const char *zFpid;
3030
+ const char *zTitle;
3031
+ const char *zIrt;
3032
+ const char *zMimetype;
3033
+ const char *zContent;
3034
+ const char *zStatus;
3035
+ const int bHasAttachment = P("file1")!=0;
3036
+ Manifest *pPost = 0;
3037
+ char *zNewUuid = 0;
3038
+ int firt = 0; /* In-reply-to rid or 0 */
3039
+ int fpid = 0; /* Post rid being edited or 0 */
3040
+ int rc = 0; /* Result code. */
3041
+ int nrid = 0; /* New artifact rid. */
3042
+ int iPostFlags; /* forum_post_flags() (after perms check) */
3043
+ int bRollback; /* True = roll back. */
3044
+ int nAttach = 0; /* Number of attachments added */
3045
+ int bStatusSet = 0; /* True if status tag set. */
3046
+
3047
+ if( !ajax_route_bootstrap(0, 1) ){
3048
+ return;
3049
+ }else if( !g.perm.WrForum
3050
+ || (bHasAttachment && !g.perm.AttachForum) ){
3051
+ ajax_route_error_forbidden();
3052
+ return;
3053
+ }else if( !ajax_check_csrf(2) ){
3054
+ ajax_route_error_csrf();
3055
+ return;
3056
+ }
3057
+
3058
+ iPostFlags = forum_post_flags(/*must come after permissions init*/);
3059
+ bRollback = (FPOST_DRYRUN & iPostFlags);
3060
+ zFpid = P("fpid");
3061
+ zIrt = P("firt");
3062
+ zMimetype = P("mimetype");
3063
+ zContent = P("content");
3064
+ zStatus = P("status");
3065
+ db_begin_transaction();
3066
+ if( zFpid && zFpid[0] ){
3067
+ fpid = symbolic_name_to_rid(zFpid, "f");
3068
+ if( fpid<0 ){
3069
+ rc = -ajax_route_error(400, "Ambiguous forum ID.");
3070
+ goto ajax_save_end;
3071
+ }else if( 0==fpid
3072
+ || 0==(pPost = manifest_get(fpid, CFTYPE_FORUM, 0)) ){
3073
+ rc = -ajax_route_error(404, "Cannot resolve forum post ID.");
3074
+ goto ajax_save_end;
3075
+ }
3076
+ }
3077
+ /*
3078
+ ** Problem: if we derive firt from fpid/pPost then there's a race
3079
+ ** condition where the IRT post is edited between the time that this
3080
+ ** edit was initiated and when it is posted: the new edit's IRT will
3081
+ ** point to the edit which was made in the meantime, not the one the
3082
+ ** user intended to respond to. However, if we accept firt from the
3083
+ ** enviornment, we "really should" validate that it's actually in
3084
+ ** the current chain, to prohibit that malicious posts could move
3085
+ ** posts around.
3086
+ **
3087
+ ** forum_post_ajax() will, if fpid>0 && !firt, select fpid's current
3088
+ ** firt.
3089
+ */
3090
+ if( zIrt && zIrt[0] ){
3091
+ firt = symbolic_name_to_rid(zIrt, "f");
3092
+ if( firt<0 ){
3093
+ rc = -ajax_route_error(400, "Ambiguous in-reply-do ID.");
3094
+ goto ajax_save_end;
3095
+ }else if( 0==firt ){
3096
+ rc = -ajax_route_error(404, "Cannot resolve in-reply-do ID.");
3097
+ goto ajax_save_end;
3098
+ }
3099
+ }
3100
+
3101
+ if( 0 ){
3102
+ rc = -ajax_route_error(400, "Save is TODO. "
3103
+ "iPostFlags=%d debug=%d",
3104
+ iPostFlags, g.perm.Debug);
3105
+ goto ajax_save_end;
3106
+ }
3107
+
3108
+ zTitle = firt ? 0 : P("title");
3109
+ nrid = forum_post_ajax(zTitle, firt, fpid, 0, zMimetype,
3110
+ zContent, iPostFlags);
3111
+ if( nrid<0 ){
3112
+ rc = nrid;
3113
+ goto ajax_save_end;
3114
+ }else if( nrid==0 ){
3115
+ if( 0==(FPOST_DRYRUN & iPostFlags) ){
3116
+ bRollback = 1;
3117
+ CX("{\"message\": \"No saving needed.\"}\n");
3118
+ }else{
3119
+ CX("{\"message\": \"Rolled back for dry-run.\","
3120
+ "\"iPostFlags\":%d}\n", iPostFlags);
3121
+ }
3122
+ goto ajax_save_end;
3123
+ }else{
3124
+ const int bNeedsModeration = forum_need_moderation();
3125
+ const int fpHead = forumpost_head_rid(nrid);
3126
+ assert( nrid>0 );
3127
+ assert( fpHead>0 );
3128
+ zNewUuid = rid_to_uuid(nrid);
3129
+ if( 0!=P("file1") ){
3130
+ /* Attachments */
3131
+ if( !g.perm.Admin && !g.perm.AttachForum ){
3132
+ rc = -ajax_route_error(403, "No permission no attach files.");
3133
+ goto ajax_save_end;
3134
+ }else{
3135
+ char *zRoot = (nrid==fpHead) ? 0 : rid_to_uuid(fpHead);
3136
+ nAttach = attachments_ajax_from_POST(zRoot ? zRoot : zNewUuid,
3137
+ bNeedsModeration);
3138
+ fossil_free(zRoot);
3139
+ if( nAttach<0 ){
3140
+ rc = nAttach;
3141
+ goto ajax_save_end;
3142
+ }
3143
+ if( nAttach>0
3144
+ && (iPostFlags & FPOST_NO_ALERT)!=0
3145
+ && db_table_exists("repository","pending_alert") ){
3146
+ /* Unqueue any alerts for these attachments. Recall that
3147
+ ** they're attached to the first version of the post, which
3148
+ ** means we actually risk cancelling _other_ pending
3149
+ ** notifications for attachments on this same post. C'est la
3150
+ ** vie.*/
3151
+ db_multi_exec(
3152
+ "WITH x(id) AS (\n"
3153
+ " SELECT 'f%d'\n"
3154
+ " UNION ALL\n"
3155
+ " SELECT 'f'||a.attachid FROM blob b, attachment a\n"
3156
+ " WHERE b.rid=%d\n"
3157
+ " AND b.uuid=a.target\n"
3158
+ ") DELETE FROM pending_alert WHERE eventid IN x",
3159
+ fpHead, fpHead
3160
+ );
3161
+ }
3162
+ }
3163
+ }
3164
+ if( 0==bNeedsModeration
3165
+ /* ^^^ Do not allow a status tag on a pending-moderation post
3166
+ ** because it will introduce a reference to an artifact which
3167
+ ** will become a phantom if it is rejected by a moderator. */
3168
+ && zStatus!=0 && zStatus[0]!=0
3169
+ && forum_may_set_status(nrid)
3170
+ && (bStatusSet=forumpost_tag(nrid, 1, "status", zStatus))<0 ){
3171
+ rc = -ajax_route_error(500, "Tagging failed: %s", g.zErrMsg);
3172
+ goto ajax_save_end;
3173
+ }
3174
+ }
3175
+
3176
+ assert( 0==rc );
3177
+ assert( zNewUuid );
3178
+ CX("{\"uuid\": %!j, \"attachedCount\": %d, "
3179
+ "\"statusModified\": %d, "
3180
+ "\"dryrun\": %s, \"iPostFlags\":%d}\n",
3181
+ zNewUuid, nAttach,
3182
+ bStatusSet, bRollback ? "true" : "false", iPostFlags);
3183
+
3184
+ajax_save_end:
3185
+ manifest_destroy(pPost);
3186
+ fossil_free(zNewUuid);
3187
+ db_end_transaction(rc || bRollback);
3188
+}
27283189
27293190
ADDED src/fossil.attach.js
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
96
97 /*
98 ** Returns a high-level representation of the forum-statuses setting.
99 ** This is a singleton, cached across calls.
100 */
101 static const ForumStatusList * forum_statuses(void){
102 static ForumStatusList fses = {0,0};
103 static int once = 0;
104 while( !once ){
105 ++once;
106 /* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151 ** found, the corresponding object is returned. If no match is found
152 ** then (A) if bFirst is false then 0 is returned, else (B) the first
153 ** entry in the list is returned, noting that the list may be empty,
154 ** in which case 0 is returned.
155 */
156 const ForumStatus * forum_status_by_value(const char *z, int bFirst){
 
 
157 const ForumStatusList * const fses = forum_statuses();
158 const ForumStatus * fs0 = 0;
159 unsigned int i;
160 if( !fses->n ) return 0;
161 for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,18 @@
227 }
228
229 /*
230 ** Works like forumpost_head_rid() but expects zUuid to be an
231 ** unambiguous forum post name. It may be a hash prefix, so long as
232 ** it's unambiguous. Returns 0 if the name cannot be unambiguously
233 ** resolved as a forum post.
234 */
235 int forumpost_head_rid2(const char *zUuid){
236 const int fpid = symbolic_name_to_rid(zUuid, "f");
237 return fpid>0
238 ? forumpost_head_rid(fpid)
239 : 0;
240 }
241
242 /*
243 ** Given a forum post RID and user name, returns true if zUserName
244 ** matches the event.(euser,user) field for a formpost entry with the
@@ -408,18 +410,17 @@
408 ** no tag is added. Similarly, it will only remove a tag from a post
409 ** which has its own tag, and will not remove an inherited one from a
410 ** parent post.
411 **
412 ** If addTag is true and frid is already tagged, this is a
413 ** no-op. Likewise, if addTag is false and frid is not tagged
414 ** (not accounting for an inherited closed tag), this is a no-op.
415 **
416 ** If bCheckIrt is true then the forum post IRT hierarchy is searched
417 ** for the tag, otherwise only the given RID is checked.
418 **
419 ** Returns true if it actually creates a new tag, else false. Fails
420 ** fatally on error.
421 **
422 ** If it returns true then state from previously-loaded posts may be
423 ** invalidated if they refer to the amended post or a response to it.
424 ** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
425 ** stale.
@@ -438,11 +439,12 @@
438 **
439 ** - The applied tag is propagating so so that "closed" tags can
440 ** account for how edits of posts are handled. This differs from
441 ** closure of a branch, where a non-propagating tag is used.
442 */
443 static int forumpost_tag(int frid, const char *zTagName, int addTag,
 
444 const char *zValue){
445 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
446 Blob cksum = BLOB_INITIALIZER; /* Z-card */
447 int iTagged; /* true if frid is already tagged */
448 int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
461 zValue = 0;
462 }
463 if( addTag && iTagged ){
464 char *zOld = 0;
465 int cmp;
466 rid_has_tag2(iTagged, zTagName, &zOld);
467 cmp = fossil_strcmp(zOld, zValue);
468 fossil_free(zOld);
469 if( 0==cmp ){
470 /* Same value - leave it as is. */
471 db_end_transaction(0);
@@ -481,22 +483,58 @@
481 md5sum_blob(&artifact, &cksum);
482 blob_appendf(&artifact, "Z %b\n", &cksum);
483 blob_reset(&cksum);
484 trid = content_put_ex(&artifact, 0, 0, 0, 0);
485 if( trid==0 ){
486 fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
487 }
488 if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
489 fossil_fatal("%s", g.zErrMsg);
490 }
491 assert( blob_is_reset(&artifact) );
492 db_add_unsent(trid);
493 admin_log("Tag forum post %S with %c%s",
494 zUuid, addTag ? '*' : '-', zTagName);
495 fossil_free(zUuid);
496 db_end_transaction(0);
497 return 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498 }
499
500 /*
501 ** Returns true if the forum-close-policy setting is true, else false,
502 ** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
894 break;
895 }
896 }
897 if( !sCurrent ) sCurrent = &fss->aStatus[0];
898 assert( sCurrent );
899 @ <span class='forum-status-selection'>
 
 
 
 
 
 
 
 
900 if( forum_may_set_status(fp->fpid) ){
901 @ <form method="post" action='%R/forumpost_status'>
902 login_insert_csrf_secret();
903 @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
904 @ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
917 @ </form>
918 /* Form is activated in fossil.page.forumpost.js */
919 }else{
920 @ <button disabled>Status: %h(sCurrent->zLabel)</button>
921 }
922 @ </span>
923 fossil_free(zCurrent);
924 }
925 }
926
927 /*
@@ -1045,17 +1091,26 @@
1045 /*
1046 ** Renders the attachment list for the given forum post.
1047 ** Emits no output if there are no attachments.
1048 */
1049 static void forum_render_attachment_list(const char *zUuid){
1050 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1051 "Attachments:</a>", zUuid);
1052 attachment_list(zUuid, zLbl,
1053 ATTACHLIST_HRULE_ABOVE
1054 | ATTACHLIST_SIZE
1055 | ATTACHLIST_HIDE_UNAPPROVED);
1056 fossil_free(zLbl);
 
 
 
 
 
 
 
 
 
1057 }
1058
1059 /*
1060 ** Renders the attachment list for p or (if not NULL) pEditHead.
1061 */
@@ -1098,24 +1153,33 @@
1098
1099 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
1100 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
1101 if( !pManifest ) return;
1102 iClosed = forumpost_is_closed(pThread, p, 1);
 
 
 
1103 /* When not in raw mode, create the border around the post. */
1104 if( !bRaw ){
1105 /* Open the <div> enclosing the post. Set the class string to mark the post
1106 ** as selected and/or obsolete. */
1107 iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1108 @ <div id='forum%d(p->fpid)' class='forumTime\
1109 @ %s(bSelect ? " forumSel" : "")\
1110 @ %s(iClosed ? " forumClosed" : "")\
1111 @ %s(p->pEditTail ? " forumObs" : "")' \
1112 if( iIndent && iIndentScale ){
1113 @ style='margin-left:%d(iIndent*iIndentScale)ex;'>
1114 }else{
1115 @ >
 
 
 
 
 
1116 }
 
1117
1118 /* If this is the first post (or an edit thereof), emit the thread title. */
1119 if( pManifest->zThreadTitle ){
1120 @ <h1>%h(pManifest->zThreadTitle)</h1>
1121 }
@@ -1196,32 +1260,33 @@
1196 /* Provide a link to the raw source code. */
1197 if( !bUnf ){
1198 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
1199 }
1200 @ </h3>
 
 
 
 
1201 }/*!bRaw*/
1202
1203 /* Check if this post is approved, also if it's by the current user. */
1204 bPrivate = content_is_private(p->fpid);
1205 bSameUser = login_is_individual()
1206 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1207
1208 /* Render the post if the user is able to see it. */
1209 if( bPrivate && !g.perm.ModForum && !bSameUser ){
1210 @ <p><span class="modpending">Awaiting Moderator Approval</span></p>
1211 }else{
1212 if( bRaw || bUnf || p->pEditTail ){
1213 zMimetype = "text/plain";
1214 }else{
1215 zMimetype = pManifest->zMimetype;
1216 }
1217 forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
1218 forum_render_attachment_list2(p);
1219 }
1220
1221 /* When not in raw mode, finish creating the border around the post. */
1222 if( !bRaw ){
 
 
1223 /* If the user is able to write to the forum and if this post has not been
1224 ** edited, create a form with various interaction buttons. */
1225 if( g.perm.WrForum && !p->pEditTail ){
1226 @ <div class="forumpost-single-controls">\
1227 @ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
1250 @ <br><label><input type="checkbox" name="trust">
1251 @ Trust user "%h(pManifest->zUser)" so that future posts by \
1252 @ "%h(pManifest->zUser)" do not require moderation.
1253 @ </label>
1254 @ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
 
1255 }
1256 }else if( bSameUser ){
1257 /* Allow users to delete (reject) their own pending posts. */
1258 @ <input type="submit" name="reject" value="Delete">
1259 }
@@ -1273,23 +1339,19 @@
1273 @ %s(iClosed ? "action-reopen" : "action-close")'/>
1274 /* ^^^ activated by fossil.page.forumpost.js */
1275 }
1276 @ </form>
1277 }
1278 if( g.perm.Admin ||
1279 (login_is_individual()
1280 && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1281 /* When an admin edits someone else's post, the admin
1282 ** effectively takes over ownership of it (and we currently
1283 ** have no way of passing it back). Because of this, we
1284 ** check the ownership of `p` instead of `pHead`. */
1285 @ <form method="post" action="%R/attachadd">\
1286 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1287 @ <input type="submit" value="Attach...">
1288 login_insert_csrf_secret();
1289 moderation_pending_www(p->fpid);
1290 @ </form>
1291 }
1292 }
1293 @ </div>
1294 }
1295 if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
1475 ** to all forum-related pages. It does not include page-specific
1476 ** code (e.g. "forum.js").
1477 */
1478 static void forum_emit_js(void){
1479 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1480 NULL);
 
1481 builtin_request_js("fossil.page.forumpost.js");
1482 }
1483
1484 /*
1485 ** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
1635 if( g.perm.WrTForum ) return 0;
1636 if( g.perm.ModForum ) return 0;
1637 return 1;
1638 }
1639
1640 /*
1641 ** Return true if the string is white-space only.
1642 */
1643 static int whitespace_only(const char *z){
1644 if( z==0 ) return 1;
1645 while( z[0] && fossil_isspace(z[0]) ){ z++; }
1646 return z[0]==0;
1647 }
1648
1649 /* Flags for use with forum_post() */
1650 #define FPOST_NO_ALERT 1 /* do not send any alerts */
 
1651
1652 /*
1653 ** Return a flags value for use with the final argument to
1654 ** forum_post(), extracted from the CGI environment.
1655 */
@@ -1656,10 +1711,13 @@
1656 static int forum_post_flags(void){
1657 int iPostFlags = 0;
1658 if( g.perm.Debug && P("fpsilent")!=0 ){
1659 iPostFlags |= FPOST_NO_ALERT;
1660 }
 
 
 
1661 return iPostFlags;
1662 }
1663
1664 /*
1665 ** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
1687 if( !g.perm.Admin && (iEdit || iInReplyTo)
1688 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
1689 forumpost_error_closed();
1690 return 0;
1691 }
1692 if( iEdit==0 && whitespace_only(zContent) ){
1693 return 0;
1694 }
1695 if( iInReplyTo==0 && iEdit>0 ){
1696 iBasis = iEdit;
1697 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
1712 fossil_free(zG);
1713 }
1714 if( zTitle ){
1715 blob_appendf(&x, "H %F\n", zTitle);
1716 }
1717 zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo);
1718 if( zI ){
1719 blob_appendf(&x, "I %s\n", zI);
1720 fossil_free(zI);
1721 }
1722 if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1723 blob_appendf(&x, "N %s\n", zMimetype);
 
 
1724 }
1725 if( iEdit>0 ){
1726 char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
1727 if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
1728 blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
1750 webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
1751 }
1752 webpage_assert( pPost->type==CFTYPE_FORUM );
1753 manifest_destroy(pPost);
1754
1755 if( P("dryrun") ){
1756 @ <div class='debug'>
1757 @ This is the artifact that would have been generated:
1758 @ <pre>%h(blob_str(&x))</pre>
1759 @ </div>
1760 blob_reset(&x);
@@ -1813,12 +1873,17 @@
1813 int addTag, int validFpid){
1814 if( !cgi_csrf_safe(2) ){
1815 webpage_error("CSRF validation failed");
1816 }else{
1817 const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1818 forumpost_tag(fpid, zTag, addTag, zVal);
1819 cgi_redirectf("%R/forumpost/%S",P("fpid"));
 
 
 
 
 
1820 }
1821 }
1822
1823 /*
1824 ** WEBPAGE: forumpost_close hidden
@@ -1954,22 +2019,24 @@
1954 }
1955 }
1956
1957 /*
1958 ** If the user has AttachForum permissions, emit a notice that
1959 ** attachments may be added after saving. If p is not NULL,
1960 ** also emit its list of attachments.
 
 
1961 */
1962 static void forum_render_attachment_notice(void){
1963 if( g.perm.AttachForum ){
1964 @ <div>You will be able to attach files to this post after saving
1965 @ it.</div>
1966 }
1967 }
1968
1969 /*
1970 ** WEBPAGE: forume1
1971 **
1972 ** Start a new forum thread.
1973 */
1974 void forumnew_page(void){
1975 const char *zTitle = PDT("title","");
@@ -1983,36 +2050,45 @@
1983 }
1984 if( P("submit") && cgi_csrf_safe(2) ){
1985 if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
1986 forum_post_flags()) ) return;
1987 }
1988 if( P("preview") && !whitespace_only(zContent) ){
1989 @ <h1>Preview:</h1>
1990 forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
1991 }
1992 style_set_current_feature("forum");
1993 style_header("New Forum Thread");
1994 @ <form action="%R/forume1" method="POST">
 
 
1995 @ <h1>New Thread:</h1>
1996 forum_from_line();
1997 forum_post_widget(zTitle, zMimetype, zContent);
1998 @ <input type="submit" name="preview" value="Preview">
1999 if( P("preview") && !whitespace_only(zContent) ){
2000 @ <input type="submit" name="submit" value="Submit">
2001 }else{
2002 @ <input type="submit" name="submit" value="Submit" disabled>
2003 }
2004 forum_render_debug_options();
2005 login_insert_csrf_secret();
2006 @ </form>
 
 
 
 
 
 
 
2007 forum_render_attachment_notice();
2008 forum_emit_js();
2009 style_finish_page();
2010 }
2011
2012 /*
2013 ** WEBPAGE: forume2
2014 **
2015 ** Edit an existing forum message.
2016 ** Query parameters:
2017 **
2018 ** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2173,11 @@
2097 style_set_current_feature("forum");
2098 isDelete = P("nullout")!=0;
2099 if( P("submit")
2100 && isCsrfSafe
2101 && (zContent = PDT("content",""))!=0
2102 && (!whitespace_only(zContent) || isDelete)
2103 ){
2104 int done = 1;
2105 const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
2106 if( bReply ){
2107 done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2251,11 @@
2175 zDisplayName = display_name_from_login(pPost->zUser);
2176 @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
2177 fossil_free(zDisplayName);
2178 fossil_free(zDate);
2179 forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2180 if( bPreview && !whitespace_only(zContent) ){
2181 @ <h2>Preview:</h2>
2182 forum_render(0, zMimetype,zContent, "forumEdit", 1);
2183 }
2184 @ <h2>Enter Reply:</h2>
2185 @ <form action="%R/forume2" method="POST">
@@ -2190,11 +2266,11 @@
2190 }
2191 if( !isDelete ){
2192 @ <input type="submit" name="preview" value="Preview">
2193 }
2194 @ <input type="submit" name="cancel" value="Cancel">
2195 if( (bPreview && !whitespace_only(zContent)) || isDelete ){
2196 if( !iClosed || g.perm.Admin ) {
2197 @ <input type="submit" name="submit" value="Submit">
2198 }
2199 }
2200 forum_render_debug_options();
@@ -2201,11 +2277,13 @@
2201 login_insert_csrf_secret();
2202 @ </form>
2203 if( !bReply ){
2204 forum_render_attachment_list(rid_to_uuid(fpid));
2205 }
2206 forum_render_attachment_notice();
 
 
2207 forum_emit_js();
2208 style_finish_page();
2209 }
2210
2211 /*
@@ -2214,21 +2292,21 @@
2214 ** to closed posts. If false, only administrators may do so. Note that
2215 ** this only affects the forum web UI, not post-closing tags which
2216 ** arrive via the command-line or from synchronization with a remote.
2217 ** This policy also determines whether moderators may delete forum
2218 ** attachments.
2219 */
2220 /*
2221 ** SETTING: forum-title width=20 default=Forum
2222 ** This is the name or "title" of the Forum for this repository. The
2223 ** default is just "Forum". But in some setups, admins might want to
2224 ** change it to "Developer Forum" or "User Forum" or whatever other name
2225 ** seems more appropriate for the particular usage.
2226 **
2227 ** SETTING: attachment-size-limit width=16
2228 ** The maximum number of bytes for an attachment. The default (or 0) is
2229 ** unlimited but a limit may be imposed by the web server or a proxy.
 
2230 **
2231 ** SETTING: forum-statuses width=40 block-text
2232 ** This JSON5-formatted value defines an array of objects describing
2233 ** the available statuses of forum posts. Each entry of the array must
2234 ** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2801,388 @@
2723 ** URL arg when the status selection list is activated. */
2724 forum_emit_js();
2725 }
2726 style_finish_page();
2727 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2728
2729 DDED src/fossil.attach.js
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
96
97 /*
98 ** Returns a high-level representation of the forum-statuses setting.
99 ** This is a singleton, cached across calls.
100 */
101 const ForumStatusList * forum_statuses(void){
102 static ForumStatusList fses = {0,0};
103 static int once = 0;
104 while( !once ){
105 ++once;
106 /* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151 ** found, the corresponding object is returned. If no match is found
152 ** then (A) if bFirst is false then 0 is returned, else (B) the first
153 ** entry in the list is returned, noting that the list may be empty,
154 ** in which case 0 is returned.
155 */
156 static const ForumStatus * forum_status_by_value(
157 const char *z, int bFirst
158 ){
159 const ForumStatusList * const fses = forum_statuses();
160 const ForumStatus * fs0 = 0;
161 unsigned int i;
162 if( !fses->n ) return 0;
163 for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,18 @@
229 }
230
231 /*
232 ** Works like forumpost_head_rid() but expects zUuid to be an
233 ** unambiguous forum post name. It may be a hash prefix, so long as
234 ** it's unambiguous. Returns the rid of the head post, -1 if the name
235 ** is ambiguous, and 0 if the name cannot be resolved as a forum post.
236 */
237 int forumpost_head_rid2(const char *zUuid){
238 const int fpid = symbolic_name_to_rid(zUuid, "f");
239 return fpid>0
240 ? forumpost_head_rid(fpid)
241 : fpid;
242 }
243
244 /*
245 ** Given a forum post RID and user name, returns true if zUserName
246 ** matches the event.(euser,user) field for a formpost entry with the
@@ -408,18 +410,17 @@
410 ** no tag is added. Similarly, it will only remove a tag from a post
411 ** which has its own tag, and will not remove an inherited one from a
412 ** parent post.
413 **
414 ** If addTag is true and frid is already tagged, this is a
415 ** no-op. Likewise, if addTag is false and frid is not tagged (not
416 ** accounting for a tag inherited via an in-response-to post), this is
417 ** a no-op.
418 **
419 ** Returns a positive value (a new tag.tagid value) if it actually
420 ** creates a new tag, else 0. On error it returns a negative alue
421 ** and g.zErrMsg "should" contain details.
 
422 **
423 ** If it returns true then state from previously-loaded posts may be
424 ** invalidated if they refer to the amended post or a response to it.
425 ** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
426 ** stale.
@@ -438,11 +439,12 @@
439 **
440 ** - The applied tag is propagating so so that "closed" tags can
441 ** account for how edits of posts are handled. This differs from
442 ** closure of a branch, where a non-propagating tag is used.
443 */
444 static int forumpost_tag(int frid, int addTag,
445 const char *zTagName,
446 const char *zValue){
447 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
448 Blob cksum = BLOB_INITIALIZER; /* Z-card */
449 int iTagged; /* true if frid is already tagged */
450 int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
463 zValue = 0;
464 }
465 if( addTag && iTagged ){
466 char *zOld = 0;
467 int cmp;
468 rid_has_tag2(frid, zTagName, &zOld);
469 cmp = fossil_strcmp(zOld, zValue);
470 fossil_free(zOld);
471 if( 0==cmp ){
472 /* Same value - leave it as is. */
473 db_end_transaction(0);
@@ -481,22 +483,58 @@
483 md5sum_blob(&artifact, &cksum);
484 blob_appendf(&artifact, "Z %b\n", &cksum);
485 blob_reset(&cksum);
486 trid = content_put_ex(&artifact, 0, 0, 0, 0);
487 if( trid==0 ){
488 return -1;
489 }
490 if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
491 return -2;
492 }
493 assert( blob_is_reset(&artifact) );
494 db_add_unsent(trid);
495 admin_log("Tag forum post %S with %c%s",
496 zUuid, addTag ? '*' : '-', zTagName);
497 fossil_free(zUuid);
498 db_end_transaction(0);
499 return trid;
500 }
501
502 /*
503 ** COMMAND: test-forumpost-tag
504 **
505 ** Usage: %fossil test-forumpost-tag ?-cancel? THREADID TAGNAME TAGVAL
506 **
507 ** A tester for forumpost_tag(). It always rolls back changes.
508 */
509 void test_forumpost_tag_command(void){
510 int fpid;
511 int rc;
512 const char *zPost;
513 const char *zTag;
514 const char *zVal;
515 const int bAdd = find_option("cancel","",0)==0;
516
517 db_find_and_open_repository(0,0);
518 verify_all_options();
519 if( g.argc<5 ){
520 usage("forum-post-id tag-name value");
521 }
522 zPost = g.argv[2];
523 zTag = g.argv[3];
524 zVal = g.argv[4];
525
526 db_begin_transaction();
527 fpid = forumpost_head_rid2(zPost);
528 if( fpid<=0 ){
529 fossil_fatal("Cannot resolve post ID %s", zPost);
530 }
531 fossil_print("%s => %d => %z\n", zTag, fpid,
532 rid_to_uuid(fpid));
533 rc = forumpost_tag(fpid, bAdd, zTag, zVal);
534 fossil_print("tag fpid=%d taxgxref.tagid=%d\n", fpid, rc);
535 db_end_transaction(1);
536 }
537
538 /*
539 ** Returns true if the forum-close-policy setting is true, else false,
540 ** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
932 break;
933 }
934 }
935 if( !sCurrent ) sCurrent = &fss->aStatus[0];
936 assert( sCurrent );
937 @ <fieldset class='forum-status-selection'>\
938 @ <legend>Status \
939 @ <span class='help-buttonlet initially-hidden'>\
940 @ Moderators and the post's owner may change \
941 @ the status of this thread unless it is still. \
942 @ pending moderation. See \
943 @ <a href='%R/help/forum-statuses' target='_new'>\
944 @ /help/forum-statuses</a></span>\
945 @ </legend>\
946 if( forum_may_set_status(fp->fpid) ){
947 @ <form method="post" action='%R/forumpost_status'>
948 login_insert_csrf_secret();
949 @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
950 @ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
963 @ </form>
964 /* Form is activated in fossil.page.forumpost.js */
965 }else{
966 @ <button disabled>Status: %h(sCurrent->zLabel)</button>
967 }
968 @ </fieldset>
969 fossil_free(zCurrent);
970 }
971 }
972
973 /*
@@ -1045,17 +1091,26 @@
1091 /*
1092 ** Renders the attachment list for the given forum post.
1093 ** Emits no output if there are no attachments.
1094 */
1095 static void forum_render_attachment_list(const char *zUuid){
1096 #if 1
1097 attachment_list(zUuid, "&#x1f4ce; Attachments", 0
1098 | ATTACHLIST_SIZE
1099 | ATTACHLIST_HIDE_UNAPPROVED
1100 | ATTACHLIST_DETAILS_CLOSED
1101 | ATTACHLIST_HIDE_EMPTY);
1102 #else
1103 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
1104 "Attachments</a>:", zUuid);
1105 attachment_list(zUuid, zLbl,
1106 ATTACHLIST_HRULE_ABOVE
1107 | ATTACHLIST_SIZE
1108 | ATTACHLIST_HIDE_UNAPPROVED
1109 | ATTACHLIST_HIDE_EMPTY);
1110 fossil_free(zLbl);
1111 #endif
1112 }
1113
1114 /*
1115 ** Renders the attachment list for p or (if not NULL) pEditHead.
1116 */
@@ -1098,24 +1153,33 @@
1153
1154 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
1155 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
1156 if( !pManifest ) return;
1157 iClosed = forumpost_is_closed(pThread, p, 1);
1158 bPrivate = content_is_private(p->fpid);
1159 bSameUser = login_is_individual()
1160 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1161 /* When not in raw mode, create the border around the post. */
1162 if( !bRaw ){
1163 /* Open the <div> enclosing the post. Set the class string to mark the post
1164 ** as selected and/or obsolete. */
1165 iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1166 @ <div id='forum%d(p->fpid)' class='forumpost forumTime\
1167 @ %s(bSelect ? " forumSel" : "")\
1168 @ %s(iClosed ? " forumClosed" : "")\
1169 @ %s(p->pEditTail ? " forumObs" : "")' \
1170 if( iIndent && iIndentScale ){
1171 @ style='margin-left:%d(iIndent*iIndentScale)ex;' \
1172 }
1173 /* These data-X fields are used by the JS editor. */
1174 if( p->pIrt ){
1175 @ data-firt="%s(p->pIrt->zUuid)" \
1176 }
1177 if( p->pEditHead ){
1178 @ data-fedithead="%s(p->pEditHead->zUuid)" \
1179 }
1180 @ data-fpid="%s(p->zUuid)">\
1181
1182 /* If this is the first post (or an edit thereof), emit the thread title. */
1183 if( pManifest->zThreadTitle ){
1184 @ <h1>%h(pManifest->zThreadTitle)</h1>
1185 }
@@ -1196,32 +1260,33 @@
1260 /* Provide a link to the raw source code. */
1261 if( !bUnf ){
1262 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
1263 }
1264 @ </h3>
1265
1266 if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){
1267 moderation_pending_www(p->fpid);
1268 }
1269 }/*!bRaw*/
1270
1271 /* Check if this post is approved, also if it's by the current user.
1272 Render the post if the user is able to see it. */
 
 
 
 
1273 if( bPrivate && !g.perm.ModForum && !bSameUser ){
1274 @ <p><span class="modpending">Awaiting Moderator Approval</span></p>
1275 }else{
1276 if( bRaw || bUnf || p->pEditTail ){
1277 zMimetype = "text/plain";
1278 }else{
1279 zMimetype = pManifest->zMimetype;
1280 }
1281 forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
 
1282 }
1283
1284 /* When not in raw mode, finish creating the border around the post. */
1285 if( !bRaw ){
1286 int bBrBeforeAttach = 0; /* Layout kludge for Attach button */
1287 forum_render_attachment_list2(p);
1288 /* If the user is able to write to the forum and if this post has not been
1289 ** edited, create a form with various interaction buttons. */
1290 if( g.perm.WrForum && !p->pEditTail ){
1291 @ <div class="forumpost-single-controls">\
1292 @ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
1315 @ <br><label><input type="checkbox" name="trust">
1316 @ Trust user "%h(pManifest->zUser)" so that future posts by \
1317 @ "%h(pManifest->zUser)" do not require moderation.
1318 @ </label>
1319 @ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
1320 bBrBeforeAttach = 1 /* slightly unmangle the layout */;
1321 }
1322 }else if( bSameUser ){
1323 /* Allow users to delete (reject) their own pending posts. */
1324 @ <input type="submit" name="reject" value="Delete">
1325 }
@@ -1273,23 +1339,19 @@
1339 @ %s(iClosed ? "action-reopen" : "action-close")'/>
1340 /* ^^^ activated by fossil.page.forumpost.js */
1341 }
1342 @ </form>
1343 }
1344 if( attach_user_may(p/*not pHead*/->fpid, CFTYPE_FORUM) ){
 
 
1345 /* When an admin edits someone else's post, the admin
1346 ** effectively takes over ownership of it (and we currently
1347 ** have no way of passing it back). Because of this, we
1348 ** check the ownership of `p` instead of `pHead`. */
1349 if( bBrBeforeAttach ){
1350 @ <br>
1351 }
1352 attach_render_attachadd_button(pHead->zUuid);
 
 
1353 }
1354 }
1355 @ </div>
1356 }
1357 if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
1537 ** to all forum-related pages. It does not include page-specific
1538 ** code (e.g. "forum.js").
1539 */
1540 static void forum_emit_js(void){
1541 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1542 "attach", "tabs", "storage",
1543 "popupwidget", NULL);
1544 builtin_request_js("fossil.page.forumpost.js");
1545 }
1546
1547 /*
1548 ** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
1698 if( g.perm.WrTForum ) return 0;
1699 if( g.perm.ModForum ) return 0;
1700 return 1;
1701 }
1702
 
 
 
 
 
 
 
 
 
1703 /* Flags for use with forum_post() */
1704 #define FPOST_NO_ALERT 1 /* do not send any alerts */
1705 #define FPOST_DRYRUN 2 /* do not save the artifact */
1706
1707 /*
1708 ** Return a flags value for use with the final argument to
1709 ** forum_post(), extracted from the CGI environment.
1710 */
@@ -1656,10 +1711,13 @@
1711 static int forum_post_flags(void){
1712 int iPostFlags = 0;
1713 if( g.perm.Debug && P("fpsilent")!=0 ){
1714 iPostFlags |= FPOST_NO_ALERT;
1715 }
1716 if( P("dryrun")!=0 ){
1717 iPostFlags |= FPOST_DRYRUN;
1718 }
1719 return iPostFlags;
1720 }
1721
1722 /*
1723 ** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
1745 if( !g.perm.Admin && (iEdit || iInReplyTo)
1746 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
1747 forumpost_error_closed();
1748 return 0;
1749 }
1750 if( iEdit==0 && fossil_all_whitespace(zContent) ){
1751 return 0;
1752 }
1753 if( iInReplyTo==0 && iEdit>0 ){
1754 iBasis = iEdit;
1755 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
1770 fossil_free(zG);
1771 }
1772 if( zTitle ){
1773 blob_appendf(&x, "H %F\n", zTitle);
1774 }
1775 zI = rid_to_uuid(iInReplyTo);
1776 if( zI ){
1777 blob_appendf(&x, "I %s\n", zI);
1778 fossil_free(zI);
1779 }
1780 if( zMimetype!=0
1781 && zMimetype[0]!=0
1782 && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1783 blob_appendf(&x, "N %F\n", zMimetype);
1784 }
1785 if( iEdit>0 ){
1786 char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
1787 if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
1788 blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
1810 webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
1811 }
1812 webpage_assert( pPost->type==CFTYPE_FORUM );
1813 manifest_destroy(pPost);
1814
1815 if( (iFlags & FPOST_DRYRUN)!=0 ){
1816 @ <div class='debug'>
1817 @ This is the artifact that would have been generated:
1818 @ <pre>%h(blob_str(&x))</pre>
1819 @ </div>
1820 blob_reset(&x);
@@ -1813,12 +1873,17 @@
1873 int addTag, int validFpid){
1874 if( !cgi_csrf_safe(2) ){
1875 webpage_error("CSRF validation failed");
1876 }else{
1877 const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1878 if( fpid>0 ){
1879 if( forumpost_tag(fpid, addTag, zTag, zVal) < 0 ){
1880 webpage_error("Tagging artifact failed: %s", g.zErrMsg);
1881 }else{
1882 cgi_redirectf("%R/forumpost/%S",P("fpid"));
1883 }
1884 }
1885 }
1886 }
1887
1888 /*
1889 ** WEBPAGE: forumpost_close hidden
@@ -1954,22 +2019,24 @@
2019 }
2020 }
2021
2022 /*
2023 ** If the user has AttachForum permissions, emit a notice that
2024 ** attachments may be added after saving. If p is not NULL, also emit
2025 ** its list of attachments. We only emit this for No-JS environments,
2026 ** as in JS environments the interactive forum editor includes
2027 ** attachment support.
2028 */
2029 static void forum_render_attachment_notice(void){
2030 if( g.perm.AttachForum ){
2031 @ <noscript><div>You will be able to attach files to this post
2032 @ after saving it.</div></noscript>
2033 }
2034 }
2035
2036 /*
2037 ** WEBPAGE: forume1 hidden
2038 **
2039 ** Start a new forum thread.
2040 */
2041 void forumnew_page(void){
2042 const char *zTitle = PDT("title","");
@@ -1983,36 +2050,45 @@
2050 }
2051 if( P("submit") && cgi_csrf_safe(2) ){
2052 if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
2053 forum_post_flags()) ) return;
2054 }
2055 if( P("preview") && !fossil_all_whitespace(zContent) ){
2056 @ <h1>Preview:</h1>
2057 forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
2058 }
2059 style_set_current_feature("forum");
2060 style_header("New Forum Thread");
2061
2062 @ <form action="%R/forume1" method="POST" \
2063 @ class="remove-if-replaced">
2064 @ <h1>New Thread:</h1>
2065 forum_from_line();
2066 forum_post_widget(zTitle, zMimetype, zContent);
2067 @ <input type="submit" name="preview" value="Preview">
2068 if( P("preview") && !fossil_all_whitespace(zContent) ){
2069 @ <input type="submit" name="submit" value="Submit">
2070 }else{
2071 @ <input type="submit" name="submit" value="Submit" disabled>
2072 }
2073 forum_render_debug_options();
2074 login_insert_csrf_secret();
2075 @ </form>
2076 /* When JS is disabled the block above will work. When it's
2077 enabled, the above will be removed and JS will render the editor
2078 form in the next element. */
2079 @ <div hidden id='forumnew-placeholder'>
2080 @ <input type='hidden' name='title' value='%h(zTitle)'>
2081 login_insert_csrf_secret();
2082 @ </div>
2083 forum_render_attachment_notice();
2084 forum_emit_js();
2085 style_finish_page();
2086 }
2087
2088 /*
2089 ** WEBPAGE: forume2 hidden
2090 **
2091 ** Edit an existing forum message.
2092 ** Query parameters:
2093 **
2094 ** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2173,11 @@
2173 style_set_current_feature("forum");
2174 isDelete = P("nullout")!=0;
2175 if( P("submit")
2176 && isCsrfSafe
2177 && (zContent = PDT("content",""))!=0
2178 && (isDelete || !fossil_all_whitespace(zContent))
2179 ){
2180 int done = 1;
2181 const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
2182 if( bReply ){
2183 done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2251,11 @@
2251 zDisplayName = display_name_from_login(pPost->zUser);
2252 @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
2253 fossil_free(zDisplayName);
2254 fossil_free(zDate);
2255 forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2256 if( bPreview && !fossil_all_whitespace(zContent) ){
2257 @ <h2>Preview:</h2>
2258 forum_render(0, zMimetype,zContent, "forumEdit", 1);
2259 }
2260 @ <h2>Enter Reply:</h2>
2261 @ <form action="%R/forume2" method="POST">
@@ -2190,11 +2266,11 @@
2266 }
2267 if( !isDelete ){
2268 @ <input type="submit" name="preview" value="Preview">
2269 }
2270 @ <input type="submit" name="cancel" value="Cancel">
2271 if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){
2272 if( !iClosed || g.perm.Admin ) {
2273 @ <input type="submit" name="submit" value="Submit">
2274 }
2275 }
2276 forum_render_debug_options();
@@ -2201,11 +2277,13 @@
2277 login_insert_csrf_secret();
2278 @ </form>
2279 if( !bReply ){
2280 forum_render_attachment_list(rid_to_uuid(fpid));
2281 }
2282 if( !isDelete ){
2283 forum_render_attachment_notice();
2284 }
2285 forum_emit_js();
2286 style_finish_page();
2287 }
2288
2289 /*
@@ -2214,21 +2292,21 @@
2292 ** to closed posts. If false, only administrators may do so. Note that
2293 ** this only affects the forum web UI, not post-closing tags which
2294 ** arrive via the command-line or from synchronization with a remote.
2295 ** This policy also determines whether moderators may delete forum
2296 ** attachments.
2297 **
 
2298 ** SETTING: forum-title width=20 default=Forum
2299 ** This is the name or "title" of the Forum for this repository. The
2300 ** default is just "Forum". But in some setups, admins might want to
2301 ** change it to "Developer Forum" or "User Forum" or whatever other name
2302 ** seems more appropriate for the particular usage.
2303 **
2304 ** SETTING: attachment-size-limit width=16
2305 ** The maximum number of bytes for an attachment to a wiki page,
2306 ** ticket, tech note, or forum post. The default (or 0) is unlimited
2307 ** but a limit may be imposed by the web server or a proxy.
2308 **
2309 ** SETTING: forum-statuses width=40 block-text
2310 ** This JSON5-formatted value defines an array of objects describing
2311 ** the available statuses of forum posts. Each entry of the array must
2312 ** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2801,388 @@
2801 ** URL arg when the status selection list is activated. */
2802 forum_emit_js();
2803 }
2804 style_finish_page();
2805 }
2806
2807 /*
2808 ** The AJAX counterpart of forum_post().
2809 **
2810 ** Returns the new artifact's RID on success, 0 if no changes were
2811 ** necessary (e.g. an empty new post or dry-run mode), and a negative
2812 ** value on error. If it returns a negative value then it will have
2813 ** populated the ajax response state with an error object.
2814 **
2815 ** zTitle must be NULL if iInReplyTo>0 and must be non-empty if
2816 ** iInReplyTo==0.
2817 **
2818 ** The caller must have started a transaction and must roll it back if
2819 ** this call returns <=0, noting that only the negative-value case is
2820 ** an error.
2821 **
2822 ** This function does some work to try to ensure that duplicate
2823 ** entries are not save (this can happen as a side effect of the forum
2824 ** post editor added in 2026-06). If the given post will not have been
2825 ** materially edited by these changes, they are not applied and the
2826 ** rid of the existing entry is used.
2827 **
2828 ** Maintenance reminders:
2829 **
2830 ** - iInReplyTo==0 && iEdit==0: new thread
2831 ** - iInReplyTo==0 && iEdit>0 : edit top post or response
2832 ** - iInReplyTo>0 && iEdit==0: new response
2833 ** - iInReplyTo>0 && iEdit>0 : edit response
2834 */
2835 static int forum_post_ajax(
2836 const char *zTitle, /* Title. NULL for replies */
2837 int iInReplyTo, /* Post replying to. 0 for new threads */
2838 int iEdit, /* Post being edited, or zero for a new post */
2839 const char *zUser, /* Username. NULL means use login name */
2840 const char *zMimetype, /* Mimetype of content. */
2841 const char *zContent, /* Content */
2842 int iFlags /* FPOST_xyz flag values */
2843 ){
2844 char *zI;
2845 char *zG;
2846 char *zP = 0;
2847 int iBasis;
2848 Blob x = BLOB_INITIALIZER,
2849 cksum = BLOB_INITIALIZER,
2850 formatCheck = BLOB_INITIALIZER,
2851 errMsg = BLOB_INITIALIZER;
2852 Manifest *pPost = 0;
2853 int nContent = zContent ? (int)strlen(zContent) : 0;
2854 int rc = 0;
2855
2856 assert( db_transaction_nesting_depth()>0 );
2857 schema_forum();
2858 if( iEdit==0 && fossil_all_whitespace(zContent) ){
2859 return 0;
2860 }
2861 if( !g.perm.Admin && (iEdit || iInReplyTo)
2862 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
2863 return -ajax_route_error(400, "Thread is closed.");
2864 }
2865 if( 0==iInReplyTo && fossil_all_whitespace(zTitle) ){
2866 return -ajax_route_error(400, "Empty title is not permitted.");
2867 }
2868
2869 if( zUser==0 ){
2870 if( login_is_nobody() ){
2871 zUser = "anonymous";
2872 }else{
2873 zUser = login_name();
2874 }
2875 }
2876 if( iEdit>0
2877 && !g.perm.Admin
2878 && !forumpost_is_owner(iEdit, zUser) ){
2879 return -ajax_route_error(
2880 403, "Only admins may edit other peoples' posts."
2881 );
2882 }
2883 if( iInReplyTo==0 && iEdit>0 ){
2884 iBasis = iEdit;
2885 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d",
2886 iEdit);
2887 }else{
2888 iBasis = iInReplyTo;
2889 /* TODO (2026-06-008) If (iInReplyTo>0 && iEdit>0), validate that
2890 ** iInReplyTo is connected to iEdit properly, else we risk
2891 ** reparenting the new edit and having unrepredictable downstream
2892 ** side effects. */
2893 }
2894
2895 if( 0!=zMimetype && 0==zMimetype[0] ){
2896 zMimetype = 0;
2897 }
2898
2899 if( 0!=zTitle && 0==zTitle[0] ) zTitle = 0;
2900 webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 );
2901
2902 if( iEdit>0 ){
2903 int cmp;
2904 pPost = manifest_get(iEdit, CFTYPE_FORUM, 0);
2905 if( pPost==0 ){
2906 rc = -ajax_route_error(404, "Missing edit artifact %d", iEdit);
2907 goto post_ajax_end;
2908 }
2909 /*
2910 ** If the old content matches the new then do not save a new copy.
2911 ** It's easy to get re-posts of unedited content via the forum
2912 ** editor, especially since the one added in 2026-06, where a
2913 ** post's status and attachments may be amended from the editor
2914 ** without modifying any of the post's content. In the legacy
2915 ** editor such "out-of-band" changes weren't possible and users
2916 ** have never made a practice of re-posting unedited content.
2917 **
2918 ** We compare the following fields to the original: user, mimetype,
2919 ** content, and (for root posts only) the title.
2920 */
2921 cmp = (0==pPost->zInReplyTo)
2922 ? fossil_strcmp(pPost->zThreadTitle, zTitle)
2923 : 0;
2924 if( 0==cmp ){
2925 cmp=fossil_strcmp(pPost->zWiki, zContent);
2926 if( 0==cmp ){
2927 cmp = fossil_strcmp(pPost->zUser, zUser);
2928 }
2929 if( 0==cmp
2930 && 0!=(cmp=fossil_strcmp(pPost->zMimetype, zMimetype)) ){
2931 /* Extra mimetype checks for a common condition seen elsewhere */
2932 if( (0==zMimetype
2933 && 0==fossil_strcmp(pPost->zMimetype, "text/x-fossil-wiki"))
2934 || (0==pPost->zMimetype
2935 && 0==fossil_strcmp(zMimetype, "text/x-fossil-wiki")) ){
2936 cmp = 0;
2937 }
2938 }
2939 if( 0==cmp ){
2940 rc = iEdit;
2941 goto post_ajax_end;
2942 }
2943 }
2944 zP = rid_to_uuid(iEdit);
2945 }
2946
2947 /* Write the new artifact */
2948 blob_init(&x, 0, 0);
2949 blob_appendf(&x, "D %z\n", date_in_standard_format("now"));
2950 zG = db_text(
2951 0,
2952 "SELECT uuid FROM blob, forumpost"
2953 " WHERE blob.rid==forumpost.froot"
2954 " AND forumpost.fpid=%d",
2955 iBasis
2956 );
2957 if( zG ){
2958 blob_appendf(&x, "G %z\n", zG);
2959 }
2960 if( zTitle ){
2961 blob_appendf(&x, "H %F\n", zTitle);
2962 }
2963 if( iInReplyTo>0 ){
2964 zI = rid_to_uuid(iInReplyTo);
2965 if( 0==zI ){
2966 rc = -ajax_route_error(404, "Missing in-reply-to artifact %d",
2967 iInReplyTo);
2968 goto post_ajax_end;
2969 }
2970 blob_appendf(&x, "I %z\n", zI);
2971 }
2972 if( zMimetype!=0
2973 && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
2974 blob_appendf(&x, "N %F\n", zMimetype);
2975 }
2976 if( zP ){
2977 blob_appendf(&x, "P %s\n", zP);
2978 }
2979
2980 blob_appendf(&x, "U %F\n", zUser);
2981 blob_appendf(&x, "W %d\n%s\n", nContent, zContent);
2982 md5sum_blob(&x, &cksum);
2983 blob_appendf(&x, "Z %b\n", &cksum);
2984 blob_reset(&cksum);
2985
2986 /* Verify that the artifact we are creating is well-formed */
2987 blob_init(&formatCheck, 0, 0);
2988 blob_init(&errMsg, 0, 0);
2989 blob_copy(&formatCheck, &x);
2990 pPost = manifest_parse(&formatCheck, 0, &errMsg);
2991 if( pPost==0 ){
2992 ajax_route_error(500, "Malformed forum post artifact: %b", &errMsg);
2993 rc = -500;
2994 goto post_ajax_end;
2995 }
2996 webpage_assert( pPost->type==CFTYPE_FORUM );
2997
2998 if( (iFlags & FPOST_DRYRUN)!=0 ){
2999 rc = 0;
3000 }else{
3001 int nrid;
3002 db_begin_transaction();
3003 nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation());
3004 blob_reset(&x);
3005 if( (iFlags & FPOST_NO_ALERT)!=0 ){
3006 alert_unqueue('f', nrid);
3007 }
3008 rc = nrid;
3009 db_end_transaction(0);
3010 }
3011 post_ajax_end:
3012 manifest_destroy(pPost);
3013 fossil_free(zP);
3014 blob_reset(&x);
3015 blob_reset(&cksum);
3016 blob_reset(&formatCheck);
3017 return rc;
3018 }
3019 /*
3020 ** WEBPAGE: forumajax_save hidden
3021 **
3022 ** WIP
3023 **
3024 ** Response JSON:
3025 **
3026 ** { uuid: hash, ...tbd }
3027 */
3028 void forum_ajax_save_page(void){
3029 const char *zFpid;
3030 const char *zTitle;
3031 const char *zIrt;
3032 const char *zMimetype;
3033 const char *zContent;
3034 const char *zStatus;
3035 const int bHasAttachment = P("file1")!=0;
3036 Manifest *pPost = 0;
3037 char *zNewUuid = 0;
3038 int firt = 0; /* In-reply-to rid or 0 */
3039 int fpid = 0; /* Post rid being edited or 0 */
3040 int rc = 0; /* Result code. */
3041 int nrid = 0; /* New artifact rid. */
3042 int iPostFlags; /* forum_post_flags() (after perms check) */
3043 int bRollback; /* True = roll back. */
3044 int nAttach = 0; /* Number of attachments added */
3045 int bStatusSet = 0; /* True if status tag set. */
3046
3047 if( !ajax_route_bootstrap(0, 1) ){
3048 return;
3049 }else if( !g.perm.WrForum
3050 || (bHasAttachment && !g.perm.AttachForum) ){
3051 ajax_route_error_forbidden();
3052 return;
3053 }else if( !ajax_check_csrf(2) ){
3054 ajax_route_error_csrf();
3055 return;
3056 }
3057
3058 iPostFlags = forum_post_flags(/*must come after permissions init*/);
3059 bRollback = (FPOST_DRYRUN & iPostFlags);
3060 zFpid = P("fpid");
3061 zIrt = P("firt");
3062 zMimetype = P("mimetype");
3063 zContent = P("content");
3064 zStatus = P("status");
3065 db_begin_transaction();
3066 if( zFpid && zFpid[0] ){
3067 fpid = symbolic_name_to_rid(zFpid, "f");
3068 if( fpid<0 ){
3069 rc = -ajax_route_error(400, "Ambiguous forum ID.");
3070 goto ajax_save_end;
3071 }else if( 0==fpid
3072 || 0==(pPost = manifest_get(fpid, CFTYPE_FORUM, 0)) ){
3073 rc = -ajax_route_error(404, "Cannot resolve forum post ID.");
3074 goto ajax_save_end;
3075 }
3076 }
3077 /*
3078 ** Problem: if we derive firt from fpid/pPost then there's a race
3079 ** condition where the IRT post is edited between the time that this
3080 ** edit was initiated and when it is posted: the new edit's IRT will
3081 ** point to the edit which was made in the meantime, not the one the
3082 ** user intended to respond to. However, if we accept firt from the
3083 ** enviornment, we "really should" validate that it's actually in
3084 ** the current chain, to prohibit that malicious posts could move
3085 ** posts around.
3086 **
3087 ** forum_post_ajax() will, if fpid>0 && !firt, select fpid's current
3088 ** firt.
3089 */
3090 if( zIrt && zIrt[0] ){
3091 firt = symbolic_name_to_rid(zIrt, "f");
3092 if( firt<0 ){
3093 rc = -ajax_route_error(400, "Ambiguous in-reply-do ID.");
3094 goto ajax_save_end;
3095 }else if( 0==firt ){
3096 rc = -ajax_route_error(404, "Cannot resolve in-reply-do ID.");
3097 goto ajax_save_end;
3098 }
3099 }
3100
3101 if( 0 ){
3102 rc = -ajax_route_error(400, "Save is TODO. "
3103 "iPostFlags=%d debug=%d",
3104 iPostFlags, g.perm.Debug);
3105 goto ajax_save_end;
3106 }
3107
3108 zTitle = firt ? 0 : P("title");
3109 nrid = forum_post_ajax(zTitle, firt, fpid, 0, zMimetype,
3110 zContent, iPostFlags);
3111 if( nrid<0 ){
3112 rc = nrid;
3113 goto ajax_save_end;
3114 }else if( nrid==0 ){
3115 if( 0==(FPOST_DRYRUN & iPostFlags) ){
3116 bRollback = 1;
3117 CX("{\"message\": \"No saving needed.\"}\n");
3118 }else{
3119 CX("{\"message\": \"Rolled back for dry-run.\","
3120 "\"iPostFlags\":%d}\n", iPostFlags);
3121 }
3122 goto ajax_save_end;
3123 }else{
3124 const int bNeedsModeration = forum_need_moderation();
3125 const int fpHead = forumpost_head_rid(nrid);
3126 assert( nrid>0 );
3127 assert( fpHead>0 );
3128 zNewUuid = rid_to_uuid(nrid);
3129 if( 0!=P("file1") ){
3130 /* Attachments */
3131 if( !g.perm.Admin && !g.perm.AttachForum ){
3132 rc = -ajax_route_error(403, "No permission no attach files.");
3133 goto ajax_save_end;
3134 }else{
3135 char *zRoot = (nrid==fpHead) ? 0 : rid_to_uuid(fpHead);
3136 nAttach = attachments_ajax_from_POST(zRoot ? zRoot : zNewUuid,
3137 bNeedsModeration);
3138 fossil_free(zRoot);
3139 if( nAttach<0 ){
3140 rc = nAttach;
3141 goto ajax_save_end;
3142 }
3143 if( nAttach>0
3144 && (iPostFlags & FPOST_NO_ALERT)!=0
3145 && db_table_exists("repository","pending_alert") ){
3146 /* Unqueue any alerts for these attachments. Recall that
3147 ** they're attached to the first version of the post, which
3148 ** means we actually risk cancelling _other_ pending
3149 ** notifications for attachments on this same post. C'est la
3150 ** vie.*/
3151 db_multi_exec(
3152 "WITH x(id) AS (\n"
3153 " SELECT 'f%d'\n"
3154 " UNION ALL\n"
3155 " SELECT 'f'||a.attachid FROM blob b, attachment a\n"
3156 " WHERE b.rid=%d\n"
3157 " AND b.uuid=a.target\n"
3158 ") DELETE FROM pending_alert WHERE eventid IN x",
3159 fpHead, fpHead
3160 );
3161 }
3162 }
3163 }
3164 if( 0==bNeedsModeration
3165 /* ^^^ Do not allow a status tag on a pending-moderation post
3166 ** because it will introduce a reference to an artifact which
3167 ** will become a phantom if it is rejected by a moderator. */
3168 && zStatus!=0 && zStatus[0]!=0
3169 && forum_may_set_status(nrid)
3170 && (bStatusSet=forumpost_tag(nrid, 1, "status", zStatus))<0 ){
3171 rc = -ajax_route_error(500, "Tagging failed: %s", g.zErrMsg);
3172 goto ajax_save_end;
3173 }
3174 }
3175
3176 assert( 0==rc );
3177 assert( zNewUuid );
3178 CX("{\"uuid\": %!j, \"attachedCount\": %d, "
3179 "\"statusModified\": %d, "
3180 "\"dryrun\": %s, \"iPostFlags\":%d}\n",
3181 zNewUuid, nAttach,
3182 bStatusSet, bRollback ? "true" : "false", iPostFlags);
3183
3184 ajax_save_end:
3185 manifest_destroy(pPost);
3186 fossil_free(zNewUuid);
3187 db_end_transaction(rc || bRollback);
3188 }
3189
3190 DDED src/fossil.attach.js
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
1
+"use strict";
2
+/**
3
+ Utility for interactive file attachment. Supports attachment
4
+ selection from a file dialog, from the clipboard, or drag/drop.
5
+
6
+ Requires that window.fossil has already been set up.
7
+ Depends on fossil.dom.
8
+*/
9
+(function(namespace){
10
+ "use strict";
11
+ const F = namespace, D = F.dom;
12
+
13
+ let idCounter = 0;
14
+ /**
15
+ Implements a multi-file selector widget. Intended to be plugged
16
+ in to places in Fossil's UI where attachments can be assigned to
17
+ an artifact.
18
+ */
19
+ class Attacher {
20
+ /* Options. */
21
+ #opt;
22
+ /* List of objects representing each row. */
23
+ #rows = [];
24
+ /* DOM elements */
25
+ #e = Object.create(null);
26
+ /* Proxy for various events this object fires. */
27
+ #events = new EventTarget();
28
+
29
+ /**
30
+ Options:
31
+
32
+ opt.container: Optional DOM element to append the resulting
33
+ widget to. If not set, the client can get access to the widget
34
+ element using this.body.
35
+
36
+ opt.addButtonLabel: optional label for the "add attachment"
37
+ button, defaulting to something generic.
38
+
39
+ opt.limit: optional max number of attachments to allow. This
40
+ defaults to "some sensible value".
41
+
42
+ opt.startWith[=0]: if >0 then that many file selection widgets
43
+ are automatically activated, as if the user had tapped the Add
44
+ button that many times.
45
+
46
+ opt.description[=true]: if true then show the file description
47
+ field, otherwise elide it.
48
+
49
+ opt.reverse[=false]: reverses the flow of the widget such that
50
+ the Add button stays on the top and rows are ordered
51
+ most-recently-added.
52
+
53
+ opt.controls = [array of DOM elements]. Optional DOM elements
54
+ to inject into the UI element which wraps the "Add" button.
55
+ See this.controlsElement.
56
+
57
+ opt.listener = function or object: {add: func, remove: func,
58
+ populate: func}: if these are functions they are registered as
59
+ listeners for 'entry-added', 'entry-removed', and/or
60
+ 'entry-populated' events, described below. opt.listener.all, if
61
+ set, is used as a fallback for any of 'add', 'remove', or
62
+ 'populate' which are not set. If opt.listener is a function
63
+ then it behaves as if listener={all: thatFunction}.
64
+
65
+ Events:
66
+
67
+ This class fires CustomEvents for certain changes:
68
+
69
+ 'entry-added' and 'entry-removed' trigger when an attachment
70
+ entry row is added/removed. Its event.detail is:
71
+
72
+ {attacher: this, row: object, type: 'same as event type'}.
73
+
74
+ 'entry-populated' is triggered when a visible entry gets
75
+ content attached to it, with the same detail structure as
76
+ described above.
77
+
78
+ The public structure of the row object passed to each is
79
+ currently TBD.
80
+ */
81
+ constructor(opt){
82
+ this.#opt = opt = F.nu({
83
+ addButtonLabel: false,
84
+ startWith: 0,
85
+ limit: 0,
86
+ dryRun: undefined,
87
+ description: true,
88
+ reverse: false
89
+ }, opt);
90
+ this.#e.body = D.addClass(D.div(), 'Attacher');
91
+ if( opt.reverse ) this.#e.body.classList.add('reverse');
92
+ const eBtnAdd = this.#e.btnAdd = D.addClass(
93
+ D.button(this.#opt.addButtonLabel || 'Add attachment',
94
+ ()=>this.#addRow()),
95
+ 'attach-add-button'
96
+ );
97
+ eBtnAdd.type = 'button';
98
+ opt.ownsAddButton = true;
99
+ this.#e.err = D.addClass(D.div(), 'error', 'hidden');
100
+ this.#e.body.append(this.#e.err);
101
+ this.#e.err.addEventListener('dblclick',()=>this.reportError());
102
+
103
+ const eControls = this.#e.controls =
104
+ D.addClass(D.div(), 'attach-controls');
105
+ eControls.append(eBtnAdd);
106
+ if( opt.container ){
107
+ opt.container.appendChild(this.#e.body);
108
+ }
109
+ this.#e.body.appendChild(eControls);
110
+ if( opt.listener ){
111
+ const doCb = (eventType, key)=>{
112
+ const f = (opt.listener instanceof Function)
113
+ ? opt.listener
114
+ : (opt.listener[key] || opt.listener.all);
115
+ if( f instanceof Function ){
116
+ this.addEventListener(eventType, f);
117
+ }
118
+ };
119
+ doCb('entry-added', 'add');
120
+ doCb('entry-removed', 'remove');
121
+ doCb('entry-populated', 'populate');
122
+ }
123
+ if( opt.dryRun ){
124
+ /* Add dry-run toggle for testing. */
125
+ const eLbl = D.label(false, "Dry-run?");
126
+ const eCb = D.checkbox(true);
127
+ eLbl.append(eCb);
128
+ eControls.append(eLbl);
129
+ eCb.checked = opt.dryRun = true;
130
+ eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
131
+ }
132
+ if( Array.isArray(opt.controls) ){
133
+ eControls.append(...opt.controls);
134
+ }
135
+ if( opt.startWith > 0 ){
136
+ for(let i = 0; i < opt.startWith; ++i ){
137
+ this.#addRow();
138
+ }
139
+ }else{
140
+ this.#updateControls();
141
+ }
142
+ }
143
+
144
+
145
+ get widget(){
146
+ return this.#e.body;
147
+ }
148
+
149
+ addEventListener(...args){
150
+ return this.#events.addEventListener(...args);
151
+ }
152
+
153
+ removeEventListener(...args){
154
+ return this.#events.removeEventListener(...args);
155
+ }
156
+
157
+ /** Returns true if any visible input widgets have content
158
+ selected. */
159
+ get isPopulated(){
160
+ for(let r of this.#rows){
161
+ if( r.file ) return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ get isDryRun(){
167
+ return !!this.#opt.dryRun;
168
+ }
169
+ /**
170
+ Returns the DOM element (div.attach-controls) which wraps the
171
+ "Add" button. Clients may add buttons to it.
172
+ */
173
+ get controlsElement(){
174
+ return this.#e.controls;
175
+ }
176
+
177
+ /**
178
+ Reports an error by appending each argument to the error widget
179
+ and unhiding it. If passed no arugments, it clears and hides
180
+ the error widget.
181
+ */
182
+ reportError(...msg){
183
+ const e = this.#e.err;
184
+ D.clearElement(e);
185
+ if( msg.length ){
186
+ e.classList.remove('hidden');
187
+ e.append(...msg);
188
+ }else{
189
+ e.classList.add('hidden');
190
+ }
191
+ }
192
+
193
+ #removeRow(rowObj){
194
+ const er = rowObj.e.row;
195
+ if( er.parentNode ){
196
+ this.#rows = this.#rows.filter(v=>v!==rowObj);
197
+ this.#updateControls();
198
+ er.classList.add('animate-exit');
199
+ er.addEventListener('animationend', ()=>er.remove(), {once: true});
200
+ this.#events.dispatchEvent(
201
+ new CustomEvent('entry-removed',{
202
+ detail: F.nu({
203
+ type: 'entry-removed',
204
+ row: rowObj,
205
+ attacher: this
206
+ })
207
+ })
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ Removes all attachments and clears the error state.
214
+ */
215
+ clear(){
216
+ for(const r of [...this.#rows/*clone because this updates #rows*/]){
217
+ this.#removeRow(r);
218
+ }
219
+ this.reportError();
220
+ }
221
+
222
+ /**
223
+ Hides or shows the Add button, as appropriate.
224
+ */
225
+ #updateControls(){
226
+ const b = this.#e.btnAdd;
227
+ if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
228
+ b.classList.add('hidden');
229
+ D.disable(b);
230
+ //F.toast.warning("Attachment form limit reached.");
231
+ }else{
232
+ b.classList.remove('hidden');
233
+ D.enable(b);
234
+ if( this.#opt.ownsAddButton ){
235
+ this.#e.body.append(this.#e.controls/*move to the end*/);
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ Returns the "Add" button widget, Passing control of it to the
242
+ caller so that they can place it in another location. This
243
+ object will still manage its enabled/disabled/hidden state but
244
+ will no longer move it when adding a row.
245
+ */
246
+ takeAddButton(){
247
+ if( this.#opt.ownsAddButton ){
248
+ this.#opt.ownsAddButton;
249
+ }
250
+ return this.#e.btnAdd;
251
+ }
252
+ /**
253
+ Sets rowObj.e.err up with an error message, or clears it if
254
+ passed only 1 argument.
255
+ */
256
+ #rowError(rowObj,...msg){
257
+ let e = rowObj.e.err;
258
+ if( e ){
259
+ D.clearElement(e);
260
+ }else{
261
+ if( !msg.length ) return;
262
+ e = rowObj.e.err = D.addClass(D.span(), 'error');
263
+ rowObj.e.info.append(e);
264
+ }
265
+ if( msg.length ){
266
+ e.append(...msg);
267
+ e.classList.remove('hidden');
268
+ }else{
269
+ e.classList.add('hidden');
270
+ }
271
+ }
272
+
273
+ #addRow(){
274
+ const id = ++idCounter;
275
+ const rowObj = F.nu({
276
+ id, file: null, mimeType: ''
277
+ });
278
+ const eRow = D.addClass(D.div(), 'attach-row');
279
+ const eDropzone = D.addClass(D.div(), 'attach-dropzone');
280
+ const eFile = D.addClass(
281
+ D.input('file'), 'attach-file-input', 'hidden'
282
+ );
283
+ const eInfo = D.addClass(D.span(), 'attach-row-info');
284
+ const eFilename = D.append(
285
+ D.addClass(D.span(), 'attach-filename'),
286
+ "Select/drop file or click the outer border and tap your "+
287
+ "platform's conventional <paste> keyboard shortcut."
288
+ );
289
+ const eSize = D.addClass(D.span(), 'attach-size');
290
+ eInfo.append(eFilename, eSize);
291
+ const eDesc = this.#opt.description
292
+ ? D.addClass(
293
+ D.attr(D.textarea(), 'placeholder',
294
+ 'Optional description...'),
295
+ 'attach-desc'
296
+ )
297
+ : undefined;
298
+ const eRemove = D.addClass(
299
+ D.button('X', (ev)=>{
300
+ ev.stopPropagation();
301
+ this.#removeRow(rowObj);
302
+ }),
303
+ 'attach-row-remove'
304
+ );
305
+ eRemove.setAttribute('title', 'Remove this attachment.');
306
+ eRemove.type = 'button';
307
+
308
+ D.append(eDropzone, eInfo, eFile, eRemove);
309
+ eDropzone.addEventListener('click', ()=>eFile.click());
310
+ eFile.addEventListener('change', (ev)=>{
311
+ if( ev.target.files.length ){
312
+ this.#injestBlob(rowObj, ev.target.files[0]);
313
+ }
314
+ });
315
+
316
+ eDropzone.addEventListener('dragover', (ev)=>{
317
+ ev.preventDefault();
318
+ eDropzone.classList.add('dragover');
319
+ });
320
+ eDropzone.addEventListener('dragleave', (ev)=>{
321
+ eDropzone.classList.remove('dragover');
322
+ });
323
+ const handleDrop = (ev, theRealRowObj)=>{
324
+ ev.preventDefault();
325
+ eDropzone.classList.remove('dragover');
326
+ if( ev.dataTransfer.files.length ){
327
+ const r = theRealRowObj || rowObj;
328
+ this.#injestBlob(r, ev.dataTransfer.files[0]);
329
+ }
330
+ };
331
+ /* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
332
+ this.#addRow();
333
+ handleDrop(ev, this.#rows[this.#rows.length-1]);
334
+ });*/
335
+ eDropzone.addEventListener('drop', handleDrop);
336
+ const pasteImage = (event, item)=>{
337
+ if( item.type.indexOf('image') === 0 ) {
338
+ event.preventDefault();
339
+ const blob = item.getAsFile();
340
+ if( blob.name === 'image.png' ){
341
+ /* Workaround to attempt to avoid name collisions when pasting
342
+ multiple images. We cannot, at this level, unambiguously
343
+ distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
344
+ file copied via a desktop file manager. */
345
+ rowObj.overrideName = `pasted-image-${Date.now()}.png`;
346
+ }
347
+ this.#injestBlob(rowObj, blob);
348
+ return true;
349
+ }
350
+ return false;
351
+ };
352
+ const pasteThing = (event, thing)=>{
353
+ if( pasteImage(event, thing) ) return true;
354
+ if( 'file' === thing.kind ){
355
+ event.preventDefault();
356
+ const blob = thing.getAsFile();
357
+ if( blob ){
358
+ this.#injestBlob(rowObj, blob);
359
+ return true;
360
+ }
361
+ }
362
+ return false;
363
+ };
364
+ eDesc?.addEventListener?.('paste', (e) => {
365
+ e.stopPropagation();
366
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
367
+ if( !items ) return;
368
+ for( let i = 0; i < items.length; ++i ){
369
+ const item = items[i];
370
+ if( pasteThing(e, item) ){
371
+ break;
372
+ }
373
+ }
374
+ });
375
+ eRow.addEventListener('paste', (e) => {
376
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
377
+ if( !items ) return;
378
+ for( let i = 0; i < items.length; ++i ){
379
+ const item = items[i];
380
+ if( item.type === 'text/plain' ){
381
+ e.preventDefault();
382
+ item.getAsString((text) => {
383
+ rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
384
+ const blob = new File([text], rowObj.overrideName,
385
+ {type: 'text/plain'});
386
+ this.#injestBlob(rowObj, blob);
387
+ });
388
+ break;
389
+ }else if( pasteThing(e, item) ){
390
+ break;
391
+ }
392
+ }
393
+ });
394
+ eRow.append(eDropzone);
395
+ if( eDesc ) eRow.append(eDesc);
396
+ rowObj.e = F.nu({
397
+ dropzone: eDropzone,
398
+ info: eInfo,
399
+ filename: eFilename,
400
+ size: eSize,
401
+ desc: eDesc,
402
+ row: eRow,
403
+ remove: eRemove
404
+ });
405
+ this.#e.body.append(eRow);
406
+ eRow.classList.add('animate-entrance');
407
+ requestAnimationFrame(() => {
408
+ eRow.scrollIntoView({
409
+ behavior: 'smooth',
410
+ block: 'nearest',
411
+ inline: 'nearest'
412
+ });
413
+ });
414
+
415
+ this.#rows.push( rowObj );
416
+ this.#updateControls();
417
+ this.#events.dispatchEvent(
418
+ new CustomEvent('entry-added',{
419
+ detail: F.nu({
420
+ type: 'entry-added',
421
+ row: rowObj,
422
+ attacher: this
423
+ })
424
+ })
425
+ );
426
+ if( 0 ){
427
+ /* To allow immediate ctrl-v, we need a trick...
428
+ But don't do this because it will interfere with, e.g.,
429
+ the forum editor. */
430
+ D.attr(eRow, 'tabindex', '-1');
431
+ eRow.focus();
432
+ }
433
+ }
434
+
435
+ #rowMatchingName(name){
436
+ for(let r of this.#rows){
437
+ if( r.file?.name===name ) return r;
438
+ }
439
+ }
440
+
441
+ /**
442
+ Injects the given File object as the attached content for the
443
+ given row. If the object's name collides with another row,
444
+ rowObj is removed from this widget and the old row is instead
445
+ re-populated with the new file.
446
+
447
+ If rowObj.overrideName is set then the given file gets wrapped
448
+ with that name before attaching it, and that property is
449
+ removed from rowObj. This is intended only for communicating
450
+ auto-generated names for pasted data.
451
+ */
452
+ #injestBlob(rowObj, file){
453
+ if( !file ) return;
454
+ const old = this.#rowMatchingName(file.name);
455
+ if( rowObj.overrideName ){
456
+ if( rowObj.overrideName !== file.name ){
457
+ file = new File([file], rowObj.overrideName, {type: file.type});
458
+ }
459
+ rowObj.overrideName = undefined;
460
+ }
461
+ if( old && rowObj !== old ){
462
+ /*
463
+ Fossil attachments treat the name as a unique-per-target
464
+ key, with the newest one being the primary. If a name is
465
+ given twice, remove the new entry and reuse the older
466
+ one. There are conceivable, but also unlikely, cases where
467
+ this will have unintended side-effects, e.g. attaching both
468
+ /foo/bar and /baz/bar, but that seems like a lesser evil
469
+ than attaching the same file N times, leading to N
470
+ attachment artifacts.
471
+ */
472
+ /* recycle `old` instead to avoid UI flicker. */
473
+ this.#rowError(old);
474
+ this.#removeRow(rowObj);
475
+ rowObj = old;
476
+ }
477
+
478
+ let szLbl;
479
+ if( file.size < 500000 ){
480
+ szLbl = file.size + ' bytes';
481
+ }else if( file.size < 1000000 ){
482
+ szLbl = (file.size / 1024).toFixed(2)+' KB';
483
+ }else{
484
+ szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
485
+ }
486
+ this.#rowError(rowObj);
487
+ rowObj.file = file;
488
+ rowObj.mimeType = file.type || 'application/octet-stream';
489
+ D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
490
+ D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
491
+ rowObj.e.dropzone.classList.add('populated');
492
+ if( rowObj.e.desc ){
493
+ rowObj.e.desc.classList.remove('hidden');
494
+ }
495
+ if( rowObj.e.thumbnail ){
496
+ rowObj.e.thumbnail.remove();
497
+ rowObj.e.thumbnail = undefined;
498
+ }
499
+ if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
500
+ /* Add a thumbnail */
501
+ const img = rowObj.e.thumbnail = D.img();
502
+ rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
503
+ img.classList.add('thumbnail');
504
+ const reader = new FileReader();
505
+ reader.onload = (e)=>img.setAttribute('src', e.target.result);
506
+ reader.readAsDataURL(file);
507
+ }
508
+ if( F.config.attachmentSizeLimit>0
509
+ && file.size>F.config.attachmentSizeLimit ){
510
+ /* Problem: tapping this link propagates its click event through
511
+ to eDropzone. Thus... */
512
+ const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
513
+ eLink.addEventListener('click', ev=>ev.stopPropagation());
514
+ this.#rowError(rowObj, "Too large: ", eLink,
515
+ " is ",F.config.attachmentSizeLimit," bytes");
516
+ rowObj.ok = false;
517
+ }else if( !file.size ){
518
+ this.#rowError(rowObj, "Cannot attach zero-byte files.");
519
+ rowObj.ok = false;
520
+ }else{
521
+ rowObj.ok = true;
522
+ }
523
+ this.#events.dispatchEvent(
524
+ new CustomEvent('entry-populated',{
525
+ detail: F.nu({
526
+ type: 'entry-populated',
527
+ row: rowObj,
528
+ attacher: this
529
+ })
530
+ })
531
+ );
532
+ }
533
+
534
+ /**
535
+ Returns an array of objects describing the currently-selected
536
+ attachments.
537
+ */
538
+ collectState(){
539
+ const rv = [];
540
+ for(let r of this.#rows){
541
+ if( !r.e.dropzone?.classList?.contains?.('populated') ){
542
+ continue;
543
+ }
544
+ rv.push(F.nu({
545
+ name: r.name || r.file.name,
546
+ content: r.file,
547
+ description: r.e.desc?.value ?? '',
548
+ mimeType: r.mimeType
549
+ }));
550
+ }
551
+ return rv;
552
+ }
553
+
554
+ /**
555
+ Populates the given FormData object with entries named
556
+ ${namePrefix}${N}, each representing a selected file and N
557
+ being a 1-based incremental counter. For entries which have a
558
+ description, it also sets ${namePrefix}${N}_desc.
559
+ */
560
+ populateFormData(fd, namePrefix='file'){
561
+ const st = this.collectState();
562
+ let i = 0;
563
+ for( ; i < st.length; ++i){
564
+ const s = st[i];
565
+ const suffix = i+1;
566
+ fd.append(`${namePrefix}${suffix}`, s.content, s.name);
567
+ const d = s.description?.trim?.();
568
+ if( d ){
569
+ fd.append(`${namePrefix}${suffix}_desc`, d);
570
+ }
571
+ }
572
+ return i;
573
+ }
574
+ }/*Attacher*/;
575
+ F.Attacher = Attacher;
576
+
577
+ F.onPageLoad(function(){
578
+ const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
579
+ if( eAttachWrapper ){
580
+ /* This page is /attachadd v2. eAttachWrapper holds
581
+ input[type=hidden] fields for use in attaching files and is
582
+ where we inject a file attachment widget. */
583
+ document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove());
584
+
585
+ eAttachWrapper.classList.remove('hidden');
586
+ const urlArgs = new URLSearchParams(window.location.search);
587
+ let zTarget = urlArgs.get('target');
588
+ let zTo = urlArgs.get('to') || urlArgs.get('from');
589
+ const eBtnSubmit = D.button("Submit");
590
+ eBtnSubmit.type = 'button';
591
+ const updateBtnSubmit = (attacher)=>{
592
+ if( attacher.isPopulated ){
593
+ eBtnSubmit.removeAttribute('disabled');
594
+ }else{
595
+ eBtnSubmit.setAttribute('disabled', '');
596
+ }
597
+ };
598
+ const cbAttacherChange = (ev)=>{
599
+ const a = ev.detail.attacher;
600
+ updateBtnSubmit(a);
601
+ };
602
+ const att = new Attacher({
603
+ container: eAttachWrapper,
604
+ startWith: 1,
605
+ listener: cbAttacherChange,
606
+ controls: [eBtnSubmit],
607
+ description: true
608
+ });
609
+ eBtnSubmit.addEventListener('click', async (ev)=>{
610
+ att.reportError();
611
+ const li = att.collectState();
612
+ if( !li.length ) return;
613
+ if( eBtnSubmit.dataset.submitted ) return;
614
+ eBtnSubmit.dataset.submitted = 1;
615
+ D.disable(eBtnSubmit);
616
+ const fd = new FormData();
617
+ att.populateFormData(fd);
618
+ for( const eIn of eAttachWrapper.querySelectorAll(
619
+ 'input[type="hidden"]'
620
+ ) ){
621
+ /* Copy over hidden input fields emitted by the server. */
622
+ if( eIn.name==='target' ){
623
+ zTarget = eIn.value;
624
+ }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
625
+ zTo = eIn.value;
626
+ }
627
+ fd.append(eIn.name, eIn.value)
628
+ }
629
+ if( att.isDryRun ){
630
+ fd.append('dryrun', '1');
631
+ }
632
+ let err;
633
+ const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
634
+ method: 'POST',
635
+ body: fd
636
+ }).catch((e)=>{
637
+ err = e;
638
+ });
639
+ D.enable(eBtnSubmit);
640
+ delete eBtnSubmit.dataset.submitted;
641
+ const jr = err ? undefined : await resp.json().catch(()=>{});
642
+ if( err || jr?.error || !resp.ok ){
643
+ const msg = err ? err.message : (jr?.error || resp.statusText);
644
+ att.reportError("Attaching failed: ", msg);
645
+ }else{
646
+ att.clear();
647
+ let to = zTo || jr?.redirect;
648
+ if( to ){
649
+ if( '/'!==to[0] ){
650
+ to = F.repoUrl(to);
651
+ }
652
+ window.location = to;
653
+ }else if( zTarget ){
654
+ window.location = '?target='+zTarget+'&'+Date.now();
655
+ }
656
+ }
657
+ })/*submit handler*/;
658
+ updateBtnSubmit(att);
659
+ F.page.attacher = att /* only for testing via dev console */;
660
+ }/* /attachadd */
661
+ })/*onPageLoad()*/;
662
+
663
+})(window.fossil);
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
1 "use strict";
2 /**
3 Utility for interactive file attachment. Supports attachment
4 selection from a file dialog, from the clipboard, or drag/drop.
5
6 Requires that window.fossil has already been set up.
7 Depends on fossil.dom.
8 */
9 (function(namespace){
10 "use strict";
11 const F = namespace, D = F.dom;
12
13 let idCounter = 0;
14 /**
15 Implements a multi-file selector widget. Intended to be plugged
16 in to places in Fossil's UI where attachments can be assigned to
17 an artifact.
18 */
19 class Attacher {
20 /* Options. */
21 #opt;
22 /* List of objects representing each row. */
23 #rows = [];
24 /* DOM elements */
25 #e = Object.create(null);
26 /* Proxy for various events this object fires. */
27 #events = new EventTarget();
28
29 /**
30 Options:
31
32 opt.container: Optional DOM element to append the resulting
33 widget to. If not set, the client can get access to the widget
34 element using this.body.
35
36 opt.addButtonLabel: optional label for the "add attachment"
37 button, defaulting to something generic.
38
39 opt.limit: optional max number of attachments to allow. This
40 defaults to "some sensible value".
41
42 opt.startWith[=0]: if >0 then that many file selection widgets
43 are automatically activated, as if the user had tapped the Add
44 button that many times.
45
46 opt.description[=true]: if true then show the file description
47 field, otherwise elide it.
48
49 opt.reverse[=false]: reverses the flow of the widget such that
50 the Add button stays on the top and rows are ordered
51 most-recently-added.
52
53 opt.controls = [array of DOM elements]. Optional DOM elements
54 to inject into the UI element which wraps the "Add" button.
55 See this.controlsElement.
56
57 opt.listener = function or object: {add: func, remove: func,
58 populate: func}: if these are functions they are registered as
59 listeners for 'entry-added', 'entry-removed', and/or
60 'entry-populated' events, described below. opt.listener.all, if
61 set, is used as a fallback for any of 'add', 'remove', or
62 'populate' which are not set. If opt.listener is a function
63 then it behaves as if listener={all: thatFunction}.
64
65 Events:
66
67 This class fires CustomEvents for certain changes:
68
69 'entry-added' and 'entry-removed' trigger when an attachment
70 entry row is added/removed. Its event.detail is:
71
72 {attacher: this, row: object, type: 'same as event type'}.
73
74 'entry-populated' is triggered when a visible entry gets
75 content attached to it, with the same detail structure as
76 described above.
77
78 The public structure of the row object passed to each is
79 currently TBD.
80 */
81 constructor(opt){
82 this.#opt = opt = F.nu({
83 addButtonLabel: false,
84 startWith: 0,
85 limit: 0,
86 dryRun: undefined,
87 description: true,
88 reverse: false
89 }, opt);
90 this.#e.body = D.addClass(D.div(), 'Attacher');
91 if( opt.reverse ) this.#e.body.classList.add('reverse');
92 const eBtnAdd = this.#e.btnAdd = D.addClass(
93 D.button(this.#opt.addButtonLabel || 'Add attachment',
94 ()=>this.#addRow()),
95 'attach-add-button'
96 );
97 eBtnAdd.type = 'button';
98 opt.ownsAddButton = true;
99 this.#e.err = D.addClass(D.div(), 'error', 'hidden');
100 this.#e.body.append(this.#e.err);
101 this.#e.err.addEventListener('dblclick',()=>this.reportError());
102
103 const eControls = this.#e.controls =
104 D.addClass(D.div(), 'attach-controls');
105 eControls.append(eBtnAdd);
106 if( opt.container ){
107 opt.container.appendChild(this.#e.body);
108 }
109 this.#e.body.appendChild(eControls);
110 if( opt.listener ){
111 const doCb = (eventType, key)=>{
112 const f = (opt.listener instanceof Function)
113 ? opt.listener
114 : (opt.listener[key] || opt.listener.all);
115 if( f instanceof Function ){
116 this.addEventListener(eventType, f);
117 }
118 };
119 doCb('entry-added', 'add');
120 doCb('entry-removed', 'remove');
121 doCb('entry-populated', 'populate');
122 }
123 if( opt.dryRun ){
124 /* Add dry-run toggle for testing. */
125 const eLbl = D.label(false, "Dry-run?");
126 const eCb = D.checkbox(true);
127 eLbl.append(eCb);
128 eControls.append(eLbl);
129 eCb.checked = opt.dryRun = true;
130 eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
131 }
132 if( Array.isArray(opt.controls) ){
133 eControls.append(...opt.controls);
134 }
135 if( opt.startWith > 0 ){
136 for(let i = 0; i < opt.startWith; ++i ){
137 this.#addRow();
138 }
139 }else{
140 this.#updateControls();
141 }
142 }
143
144
145 get widget(){
146 return this.#e.body;
147 }
148
149 addEventListener(...args){
150 return this.#events.addEventListener(...args);
151 }
152
153 removeEventListener(...args){
154 return this.#events.removeEventListener(...args);
155 }
156
157 /** Returns true if any visible input widgets have content
158 selected. */
159 get isPopulated(){
160 for(let r of this.#rows){
161 if( r.file ) return true;
162 }
163 return false;
164 }
165
166 get isDryRun(){
167 return !!this.#opt.dryRun;
168 }
169 /**
170 Returns the DOM element (div.attach-controls) which wraps the
171 "Add" button. Clients may add buttons to it.
172 */
173 get controlsElement(){
174 return this.#e.controls;
175 }
176
177 /**
178 Reports an error by appending each argument to the error widget
179 and unhiding it. If passed no arugments, it clears and hides
180 the error widget.
181 */
182 reportError(...msg){
183 const e = this.#e.err;
184 D.clearElement(e);
185 if( msg.length ){
186 e.classList.remove('hidden');
187 e.append(...msg);
188 }else{
189 e.classList.add('hidden');
190 }
191 }
192
193 #removeRow(rowObj){
194 const er = rowObj.e.row;
195 if( er.parentNode ){
196 this.#rows = this.#rows.filter(v=>v!==rowObj);
197 this.#updateControls();
198 er.classList.add('animate-exit');
199 er.addEventListener('animationend', ()=>er.remove(), {once: true});
200 this.#events.dispatchEvent(
201 new CustomEvent('entry-removed',{
202 detail: F.nu({
203 type: 'entry-removed',
204 row: rowObj,
205 attacher: this
206 })
207 })
208 );
209 }
210 }
211
212 /**
213 Removes all attachments and clears the error state.
214 */
215 clear(){
216 for(const r of [...this.#rows/*clone because this updates #rows*/]){
217 this.#removeRow(r);
218 }
219 this.reportError();
220 }
221
222 /**
223 Hides or shows the Add button, as appropriate.
224 */
225 #updateControls(){
226 const b = this.#e.btnAdd;
227 if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
228 b.classList.add('hidden');
229 D.disable(b);
230 //F.toast.warning("Attachment form limit reached.");
231 }else{
232 b.classList.remove('hidden');
233 D.enable(b);
234 if( this.#opt.ownsAddButton ){
235 this.#e.body.append(this.#e.controls/*move to the end*/);
236 }
237 }
238 }
239
240 /**
241 Returns the "Add" button widget, Passing control of it to the
242 caller so that they can place it in another location. This
243 object will still manage its enabled/disabled/hidden state but
244 will no longer move it when adding a row.
245 */
246 takeAddButton(){
247 if( this.#opt.ownsAddButton ){
248 this.#opt.ownsAddButton;
249 }
250 return this.#e.btnAdd;
251 }
252 /**
253 Sets rowObj.e.err up with an error message, or clears it if
254 passed only 1 argument.
255 */
256 #rowError(rowObj,...msg){
257 let e = rowObj.e.err;
258 if( e ){
259 D.clearElement(e);
260 }else{
261 if( !msg.length ) return;
262 e = rowObj.e.err = D.addClass(D.span(), 'error');
263 rowObj.e.info.append(e);
264 }
265 if( msg.length ){
266 e.append(...msg);
267 e.classList.remove('hidden');
268 }else{
269 e.classList.add('hidden');
270 }
271 }
272
273 #addRow(){
274 const id = ++idCounter;
275 const rowObj = F.nu({
276 id, file: null, mimeType: ''
277 });
278 const eRow = D.addClass(D.div(), 'attach-row');
279 const eDropzone = D.addClass(D.div(), 'attach-dropzone');
280 const eFile = D.addClass(
281 D.input('file'), 'attach-file-input', 'hidden'
282 );
283 const eInfo = D.addClass(D.span(), 'attach-row-info');
284 const eFilename = D.append(
285 D.addClass(D.span(), 'attach-filename'),
286 "Select/drop file or click the outer border and tap your "+
287 "platform's conventional <paste> keyboard shortcut."
288 );
289 const eSize = D.addClass(D.span(), 'attach-size');
290 eInfo.append(eFilename, eSize);
291 const eDesc = this.#opt.description
292 ? D.addClass(
293 D.attr(D.textarea(), 'placeholder',
294 'Optional description...'),
295 'attach-desc'
296 )
297 : undefined;
298 const eRemove = D.addClass(
299 D.button('X', (ev)=>{
300 ev.stopPropagation();
301 this.#removeRow(rowObj);
302 }),
303 'attach-row-remove'
304 );
305 eRemove.setAttribute('title', 'Remove this attachment.');
306 eRemove.type = 'button';
307
308 D.append(eDropzone, eInfo, eFile, eRemove);
309 eDropzone.addEventListener('click', ()=>eFile.click());
310 eFile.addEventListener('change', (ev)=>{
311 if( ev.target.files.length ){
312 this.#injestBlob(rowObj, ev.target.files[0]);
313 }
314 });
315
316 eDropzone.addEventListener('dragover', (ev)=>{
317 ev.preventDefault();
318 eDropzone.classList.add('dragover');
319 });
320 eDropzone.addEventListener('dragleave', (ev)=>{
321 eDropzone.classList.remove('dragover');
322 });
323 const handleDrop = (ev, theRealRowObj)=>{
324 ev.preventDefault();
325 eDropzone.classList.remove('dragover');
326 if( ev.dataTransfer.files.length ){
327 const r = theRealRowObj || rowObj;
328 this.#injestBlob(r, ev.dataTransfer.files[0]);
329 }
330 };
331 /* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
332 this.#addRow();
333 handleDrop(ev, this.#rows[this.#rows.length-1]);
334 });*/
335 eDropzone.addEventListener('drop', handleDrop);
336 const pasteImage = (event, item)=>{
337 if( item.type.indexOf('image') === 0 ) {
338 event.preventDefault();
339 const blob = item.getAsFile();
340 if( blob.name === 'image.png' ){
341 /* Workaround to attempt to avoid name collisions when pasting
342 multiple images. We cannot, at this level, unambiguously
343 distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
344 file copied via a desktop file manager. */
345 rowObj.overrideName = `pasted-image-${Date.now()}.png`;
346 }
347 this.#injestBlob(rowObj, blob);
348 return true;
349 }
350 return false;
351 };
352 const pasteThing = (event, thing)=>{
353 if( pasteImage(event, thing) ) return true;
354 if( 'file' === thing.kind ){
355 event.preventDefault();
356 const blob = thing.getAsFile();
357 if( blob ){
358 this.#injestBlob(rowObj, blob);
359 return true;
360 }
361 }
362 return false;
363 };
364 eDesc?.addEventListener?.('paste', (e) => {
365 e.stopPropagation();
366 const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
367 if( !items ) return;
368 for( let i = 0; i < items.length; ++i ){
369 const item = items[i];
370 if( pasteThing(e, item) ){
371 break;
372 }
373 }
374 });
375 eRow.addEventListener('paste', (e) => {
376 const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
377 if( !items ) return;
378 for( let i = 0; i < items.length; ++i ){
379 const item = items[i];
380 if( item.type === 'text/plain' ){
381 e.preventDefault();
382 item.getAsString((text) => {
383 rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
384 const blob = new File([text], rowObj.overrideName,
385 {type: 'text/plain'});
386 this.#injestBlob(rowObj, blob);
387 });
388 break;
389 }else if( pasteThing(e, item) ){
390 break;
391 }
392 }
393 });
394 eRow.append(eDropzone);
395 if( eDesc ) eRow.append(eDesc);
396 rowObj.e = F.nu({
397 dropzone: eDropzone,
398 info: eInfo,
399 filename: eFilename,
400 size: eSize,
401 desc: eDesc,
402 row: eRow,
403 remove: eRemove
404 });
405 this.#e.body.append(eRow);
406 eRow.classList.add('animate-entrance');
407 requestAnimationFrame(() => {
408 eRow.scrollIntoView({
409 behavior: 'smooth',
410 block: 'nearest',
411 inline: 'nearest'
412 });
413 });
414
415 this.#rows.push( rowObj );
416 this.#updateControls();
417 this.#events.dispatchEvent(
418 new CustomEvent('entry-added',{
419 detail: F.nu({
420 type: 'entry-added',
421 row: rowObj,
422 attacher: this
423 })
424 })
425 );
426 if( 0 ){
427 /* To allow immediate ctrl-v, we need a trick...
428 But don't do this because it will interfere with, e.g.,
429 the forum editor. */
430 D.attr(eRow, 'tabindex', '-1');
431 eRow.focus();
432 }
433 }
434
435 #rowMatchingName(name){
436 for(let r of this.#rows){
437 if( r.file?.name===name ) return r;
438 }
439 }
440
441 /**
442 Injects the given File object as the attached content for the
443 given row. If the object's name collides with another row,
444 rowObj is removed from this widget and the old row is instead
445 re-populated with the new file.
446
447 If rowObj.overrideName is set then the given file gets wrapped
448 with that name before attaching it, and that property is
449 removed from rowObj. This is intended only for communicating
450 auto-generated names for pasted data.
451 */
452 #injestBlob(rowObj, file){
453 if( !file ) return;
454 const old = this.#rowMatchingName(file.name);
455 if( rowObj.overrideName ){
456 if( rowObj.overrideName !== file.name ){
457 file = new File([file], rowObj.overrideName, {type: file.type});
458 }
459 rowObj.overrideName = undefined;
460 }
461 if( old && rowObj !== old ){
462 /*
463 Fossil attachments treat the name as a unique-per-target
464 key, with the newest one being the primary. If a name is
465 given twice, remove the new entry and reuse the older
466 one. There are conceivable, but also unlikely, cases where
467 this will have unintended side-effects, e.g. attaching both
468 /foo/bar and /baz/bar, but that seems like a lesser evil
469 than attaching the same file N times, leading to N
470 attachment artifacts.
471 */
472 /* recycle `old` instead to avoid UI flicker. */
473 this.#rowError(old);
474 this.#removeRow(rowObj);
475 rowObj = old;
476 }
477
478 let szLbl;
479 if( file.size < 500000 ){
480 szLbl = file.size + ' bytes';
481 }else if( file.size < 1000000 ){
482 szLbl = (file.size / 1024).toFixed(2)+' KB';
483 }else{
484 szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
485 }
486 this.#rowError(rowObj);
487 rowObj.file = file;
488 rowObj.mimeType = file.type || 'application/octet-stream';
489 D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
490 D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
491 rowObj.e.dropzone.classList.add('populated');
492 if( rowObj.e.desc ){
493 rowObj.e.desc.classList.remove('hidden');
494 }
495 if( rowObj.e.thumbnail ){
496 rowObj.e.thumbnail.remove();
497 rowObj.e.thumbnail = undefined;
498 }
499 if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
500 /* Add a thumbnail */
501 const img = rowObj.e.thumbnail = D.img();
502 rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
503 img.classList.add('thumbnail');
504 const reader = new FileReader();
505 reader.onload = (e)=>img.setAttribute('src', e.target.result);
506 reader.readAsDataURL(file);
507 }
508 if( F.config.attachmentSizeLimit>0
509 && file.size>F.config.attachmentSizeLimit ){
510 /* Problem: tapping this link propagates its click event through
511 to eDropzone. Thus... */
512 const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
513 eLink.addEventListener('click', ev=>ev.stopPropagation());
514 this.#rowError(rowObj, "Too large: ", eLink,
515 " is ",F.config.attachmentSizeLimit," bytes");
516 rowObj.ok = false;
517 }else if( !file.size ){
518 this.#rowError(rowObj, "Cannot attach zero-byte files.");
519 rowObj.ok = false;
520 }else{
521 rowObj.ok = true;
522 }
523 this.#events.dispatchEvent(
524 new CustomEvent('entry-populated',{
525 detail: F.nu({
526 type: 'entry-populated',
527 row: rowObj,
528 attacher: this
529 })
530 })
531 );
532 }
533
534 /**
535 Returns an array of objects describing the currently-selected
536 attachments.
537 */
538 collectState(){
539 const rv = [];
540 for(let r of this.#rows){
541 if( !r.e.dropzone?.classList?.contains?.('populated') ){
542 continue;
543 }
544 rv.push(F.nu({
545 name: r.name || r.file.name,
546 content: r.file,
547 description: r.e.desc?.value ?? '',
548 mimeType: r.mimeType
549 }));
550 }
551 return rv;
552 }
553
554 /**
555 Populates the given FormData object with entries named
556 ${namePrefix}${N}, each representing a selected file and N
557 being a 1-based incremental counter. For entries which have a
558 description, it also sets ${namePrefix}${N}_desc.
559 */
560 populateFormData(fd, namePrefix='file'){
561 const st = this.collectState();
562 let i = 0;
563 for( ; i < st.length; ++i){
564 const s = st[i];
565 const suffix = i+1;
566 fd.append(`${namePrefix}${suffix}`, s.content, s.name);
567 const d = s.description?.trim?.();
568 if( d ){
569 fd.append(`${namePrefix}${suffix}_desc`, d);
570 }
571 }
572 return i;
573 }
574 }/*Attacher*/;
575 F.Attacher = Attacher;
576
577 F.onPageLoad(function(){
578 const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
579 if( eAttachWrapper ){
580 /* This page is /attachadd v2. eAttachWrapper holds
581 input[type=hidden] fields for use in attaching files and is
582 where we inject a file attachment widget. */
583 document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove());
584
585 eAttachWrapper.classList.remove('hidden');
586 const urlArgs = new URLSearchParams(window.location.search);
587 let zTarget = urlArgs.get('target');
588 let zTo = urlArgs.get('to') || urlArgs.get('from');
589 const eBtnSubmit = D.button("Submit");
590 eBtnSubmit.type = 'button';
591 const updateBtnSubmit = (attacher)=>{
592 if( attacher.isPopulated ){
593 eBtnSubmit.removeAttribute('disabled');
594 }else{
595 eBtnSubmit.setAttribute('disabled', '');
596 }
597 };
598 const cbAttacherChange = (ev)=>{
599 const a = ev.detail.attacher;
600 updateBtnSubmit(a);
601 };
602 const att = new Attacher({
603 container: eAttachWrapper,
604 startWith: 1,
605 listener: cbAttacherChange,
606 controls: [eBtnSubmit],
607 description: true
608 });
609 eBtnSubmit.addEventListener('click', async (ev)=>{
610 att.reportError();
611 const li = att.collectState();
612 if( !li.length ) return;
613 if( eBtnSubmit.dataset.submitted ) return;
614 eBtnSubmit.dataset.submitted = 1;
615 D.disable(eBtnSubmit);
616 const fd = new FormData();
617 att.populateFormData(fd);
618 for( const eIn of eAttachWrapper.querySelectorAll(
619 'input[type="hidden"]'
620 ) ){
621 /* Copy over hidden input fields emitted by the server. */
622 if( eIn.name==='target' ){
623 zTarget = eIn.value;
624 }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
625 zTo = eIn.value;
626 }
627 fd.append(eIn.name, eIn.value)
628 }
629 if( att.isDryRun ){
630 fd.append('dryrun', '1');
631 }
632 let err;
633 const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
634 method: 'POST',
635 body: fd
636 }).catch((e)=>{
637 err = e;
638 });
639 D.enable(eBtnSubmit);
640 delete eBtnSubmit.dataset.submitted;
641 const jr = err ? undefined : await resp.json().catch(()=>{});
642 if( err || jr?.error || !resp.ok ){
643 const msg = err ? err.message : (jr?.error || resp.statusText);
644 att.reportError("Attaching failed: ", msg);
645 }else{
646 att.clear();
647 let to = zTo || jr?.redirect;
648 if( to ){
649 if( '/'!==to[0] ){
650 to = F.repoUrl(to);
651 }
652 window.location = to;
653 }else if( zTarget ){
654 window.location = '?target='+zTarget+'&'+Date.now();
655 }
656 }
657 })/*submit handler*/;
658 updateBtnSubmit(att);
659 F.page.attacher = att /* only for testing via dev console */;
660 }/* /attachadd */
661 })/*onPageLoad()*/;
662
663 })(window.fossil);
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
1717
initialized that object.
1818
*/
1919
2020
const F = global.fossil;
2121
22
+ /** Creates a prototype-less plain object with properties derived
23
+ from all of its object-type arguments. */
24
+ F.nu = (...obj)=>Object.assign(Object.create(null),...obj);
25
+
2226
/**
2327
Returns the current time in something approximating
2428
ISO-8601 format.
2529
*/
2630
const timestring = function f(){
@@ -54,11 +58,11 @@
5458
** removed from the object. Pass it a falsy value to clear the target
5559
** element.
5660
**
5761
** Returns this object.
5862
*/
59
- F.message = function f(msg){
63
+ F.message = function f(){
6064
const args = Array.prototype.slice.call(arguments,0);
6165
const tgt = f.targetElement;
6266
if(args.length) args.unshift(
6367
localTimeString()+':'
6468
//timestring(),'UTC:'
@@ -84,26 +88,25 @@
8488
F.message.targetElement.addEventListener(
8589
'dblclick', ()=>F.message(), false
8690
);
8791
}
8892
/*
89
- ** By default fossil.error() sends its first argument to
93
+ ** By default fossil.error() sends all arguments to
9094
** console.error(). If fossil.message.targetElement (yes,
9195
** fossil.message) is set, it adds the 'error' CSS class to
9296
** that element and sets its content as defined for message().
9397
**
9498
** Returns this object.
9599
*/
96
- F.error = function f(msg){
100
+ F.error = function f(){
97101
const args = Array.prototype.slice.call(arguments,0);
98102
const tgt = F.message.targetElement;
99103
args.unshift(timestring(),'UTC:');
100104
if(tgt){
101105
tgt.classList.add('error');
102106
tgt.innerText = args.join(' ');
103
- }
104
- else{
107
+ }else{
105108
args.unshift('Fossil error:');
106109
console.error.apply(console,args);
107110
}
108111
return this;
109112
};
110113
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
17 initialized that object.
18 */
19
20 const F = global.fossil;
21
 
 
 
 
22 /**
23 Returns the current time in something approximating
24 ISO-8601 format.
25 */
26 const timestring = function f(){
@@ -54,11 +58,11 @@
54 ** removed from the object. Pass it a falsy value to clear the target
55 ** element.
56 **
57 ** Returns this object.
58 */
59 F.message = function f(msg){
60 const args = Array.prototype.slice.call(arguments,0);
61 const tgt = f.targetElement;
62 if(args.length) args.unshift(
63 localTimeString()+':'
64 //timestring(),'UTC:'
@@ -84,26 +88,25 @@
84 F.message.targetElement.addEventListener(
85 'dblclick', ()=>F.message(), false
86 );
87 }
88 /*
89 ** By default fossil.error() sends its first argument to
90 ** console.error(). If fossil.message.targetElement (yes,
91 ** fossil.message) is set, it adds the 'error' CSS class to
92 ** that element and sets its content as defined for message().
93 **
94 ** Returns this object.
95 */
96 F.error = function f(msg){
97 const args = Array.prototype.slice.call(arguments,0);
98 const tgt = F.message.targetElement;
99 args.unshift(timestring(),'UTC:');
100 if(tgt){
101 tgt.classList.add('error');
102 tgt.innerText = args.join(' ');
103 }
104 else{
105 args.unshift('Fossil error:');
106 console.error.apply(console,args);
107 }
108 return this;
109 };
110
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
17 initialized that object.
18 */
19
20 const F = global.fossil;
21
22 /** Creates a prototype-less plain object with properties derived
23 from all of its object-type arguments. */
24 F.nu = (...obj)=>Object.assign(Object.create(null),...obj);
25
26 /**
27 Returns the current time in something approximating
28 ISO-8601 format.
29 */
30 const timestring = function f(){
@@ -54,11 +58,11 @@
58 ** removed from the object. Pass it a falsy value to clear the target
59 ** element.
60 **
61 ** Returns this object.
62 */
63 F.message = function f(){
64 const args = Array.prototype.slice.call(arguments,0);
65 const tgt = f.targetElement;
66 if(args.length) args.unshift(
67 localTimeString()+':'
68 //timestring(),'UTC:'
@@ -84,26 +88,25 @@
88 F.message.targetElement.addEventListener(
89 'dblclick', ()=>F.message(), false
90 );
91 }
92 /*
93 ** By default fossil.error() sends all arguments to
94 ** console.error(). If fossil.message.targetElement (yes,
95 ** fossil.message) is set, it adds the 'error' CSS class to
96 ** that element and sets its content as defined for message().
97 **
98 ** Returns this object.
99 */
100 F.error = function f(){
101 const args = Array.prototype.slice.call(arguments,0);
102 const tgt = F.message.targetElement;
103 args.unshift(timestring(),'UTC:');
104 if(tgt){
105 tgt.classList.add('error');
106 tgt.innerText = args.join(' ');
107 }else{
 
108 args.unshift('Fossil error:');
109 console.error.apply(console,args);
110 }
111 return this;
112 };
113
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
8080
Returns a LABEL element. If passed an argument,
8181
it must be an id or an HTMLElement with an id,
8282
and that id is set as the 'for' attribute of the
8383
label. If passed 2 arguments, the 2nd is text or
8484
a DOM element to append to the label.
85
+
86
+ 2026-06: this is a goofy interface. Generally simpler that
87
+ dealing with IDs is to embed the target control within the label
88
+ element.
8589
*/
8690
dom.label = function(forElem, text){
8791
const rc = document.createElement('label');
8892
if(forElem){
8993
if(forElem instanceof HTMLElement){
9094
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
80 Returns a LABEL element. If passed an argument,
81 it must be an id or an HTMLElement with an id,
82 and that id is set as the 'for' attribute of the
83 label. If passed 2 arguments, the 2nd is text or
84 a DOM element to append to the label.
 
 
 
 
85 */
86 dom.label = function(forElem, text){
87 const rc = document.createElement('label');
88 if(forElem){
89 if(forElem instanceof HTMLElement){
90
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
80 Returns a LABEL element. If passed an argument,
81 it must be an id or an HTMLElement with an id,
82 and that id is set as the 'for' attribute of the
83 label. If passed 2 arguments, the 2nd is text or
84 a DOM element to append to the label.
85
86 2026-06: this is a goofy interface. Generally simpler that
87 dealing with IDs is to embed the target control within the label
88 element.
89 */
90 dom.label = function(forElem, text){
91 const rc = document.createElement('label');
92 if(forElem){
93 if(forElem instanceof HTMLElement){
94
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
1
+/**
2
+ Code for the forum family of pages. Requires fossil.X where X is
3
+ (copybutton, pikchr, confirmer, attach, tabs, storage).
4
+*/
15
(function(F/*the fossil object*/){
26
"use strict";
37
/* JS code for /forumpost and friends. Requires fossil.dom
48
and can optionally use fossil.pikchr. */
59
const P = F.page, D = F.dom;
610
11
+ let idCounter = 0;
12
+
13
+ /*
14
+ The problem: when previewing the bottom-most post of a thread, the
15
+ preview widget's size changes cause the page to scroll
16
+ unpredictably as the bottom boundary of the page moves. A weird
17
+ workaround (not invented here) is to add dummy blank padding to
18
+ the page to allow the preview widget to grow and shrink without
19
+ (usually) scrolling, but whether it does so really depends on its
20
+ size.
21
+
22
+ We could maybe get the same effect by adding this size as
23
+ padding-bottom to document.body instead of as a new element.
24
+ */
25
+ const dummyPadding = D.div();
26
+ dummyPadding.style.height = '75em';
27
+ /* Keep track of ForumPostEditor instances so we can remove this
28
+ padding when none are active. */
29
+ dummyPadding.refs = new Set();
30
+ F.dummyPadding = dummyPadding /* only for debugging */;
31
+
32
+ /**
33
+ A forum post editor widget for new posts and responses.
34
+ */
35
+ class ForumPostEditor {
36
+ /* Options */
37
+ #opt;
38
+ /* Dom elements */
39
+ #e;
40
+ /* F.Attacher instance */
41
+ #att;
42
+ /* Is waiting on a pending remote response. */
43
+ #isWaiting = false;
44
+ /* F.TabManager */
45
+ #tabs;
46
+ /* Elements to disable while an XHR is pending. */
47
+ #toDisable = [];
48
+ /* DOM element of the current active tab. */
49
+ #activeTab;
50
+ /* Extra input[type=hidden] fields imported from fossil's
51
+ static page generation. */
52
+ #extraFields;
53
+ /* Persistent draft message object. */
54
+ #draft;
55
+
56
+ /**
57
+ Options:
58
+
59
+ opt.draftKey[string=undefined]: if set then this object's state
60
+ will be stored in fossil.storage when the relevant input fields
61
+ lose focus. If old state is found, the form is pre-populated
62
+ from it. The state is cleared on a discard() or successful
63
+ submit.
64
+
65
+ opt.ondiscard[=function]: if set, a Discard button is added
66
+ which, when activated, clears the current draft and removes
67
+ this object's widget from the DOM. After doing so,
68
+ opt.ondiscard() is called and passed this object. Exceptions
69
+ thrown by ondiscard() are ignored but may be logged.
70
+
71
+ opt.onsubmit[=function]: if set, this function is called
72
+ immediately after the post has been successfully saved, and
73
+ passed this object and a JSON-format response object from the
74
+ save request. It is generally then up to the caller to close()
75
+ this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
+
77
+ opt.onclose[=function]: like opt.onsubmit, this function is
78
+ called when this.close() is called, and passed no arguments.
79
+ onclose() is called before the widget is removed from the dom
80
+ and _does not_ fire if it is not in the DOM.
81
+
82
+ opt.hiddenFields: an optional list of input elements to
83
+ incorporate into the form for requests which request the
84
+ preview or save the post.
85
+
86
+ opt.inReplyTo=uuid: if this is a response to a post, this
87
+ is the full forum post uuid of the being-replied-to post.
88
+
89
+ opt.edit=artifactObject: if this is an edit of an existing
90
+ post, this is the full JSON-format artifact of the forum post
91
+ the being-edited post, as returned by /ajax/artifact.json.
92
+
93
+ opt.status: optional current status tag value for opt.edit,
94
+ if known. This is used for pre-selecting a status value.
95
+
96
+ opt.hideStash[bool=false]: if true, the "Stash" button does not
97
+ get added. Intended for use with /forumnew.
98
+ */
99
+ constructor(opt){
100
+ opt = this.#opt = F.nu({
101
+ draftKey: undefined,
102
+ hideStash: false
103
+ }, opt);
104
+ opt.isNewThread = !opt.inReplyTo && !opt.edit;
105
+ if( opt.draftKey ){
106
+ this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {}));
107
+ }
108
+ const e = this.#e = F.nu({
109
+ mimetype: F.nu(),
110
+ button: F.nu()
111
+ });
112
+ //console.debug("Setting up FPE opt =",opt);
113
+ const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
114
+ D.clearElement(wrapper);
115
+
116
+ if( !opt.inReplyTo ){
117
+ /* Title... */
118
+ e.titleBar = D.addClass(D.div(),'titlebar');
119
+ e.title = D.attr(
120
+ D.addClass(D.input('text'), 'title'),
121
+ 'placeholder',
122
+ 'Thread title (required)'
123
+ );
124
+ e.title.setAttribute('maxlength', 125);
125
+ e.titleBar.append(
126
+ D.append(D.span(), "Title:"),
127
+ e.title
128
+ );
129
+ if( this.#draft ){
130
+ e.title.addEventListener('blur', ()=>{
131
+ this.#draft.title = e.title.value;
132
+ this.#storeDraft();
133
+ });
134
+ e.title.value = this.#draft.title || opt.edit?.H || '';
135
+ }else if( opt.edit?.H ){
136
+ e.title.value = opt.edit.H;
137
+ }
138
+ wrapper.append(e.titleBar);
139
+ }
140
+
141
+ { /* Mimetype... */
142
+ e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
143
+ const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
144
+ sel.setAttribute('title', 'Markup format for this post.');
145
+ this.#toDisable.push(sel);
146
+ let i = 0;
147
+ D.option(sel, '', '- Markup format -').disabled = true;
148
+ for(const [k,v] of Object.entries({
149
+ 'text/x-markdown': 'Markdown',
150
+ 'text/x-fossil-wiki': 'Fossil Wiki',
151
+ 'text/plain': 'Plain text'
152
+ })) {
153
+ D.option(sel, k, v);
154
+ }
155
+ sel.value = opt.mimetype
156
+ || this.#draft?.mimetype
157
+ || F.storage.get('forum-mimetype', sel.options[1].value);
158
+ sel.addEventListener('change',ev=>{
159
+ if( this.#draft && this.#draft.mimetype!==ev.target.value ){
160
+ this.#draft.mimetype = ev.target.value;
161
+ this.#storeDraft();
162
+ }
163
+ F.storage.set('forum-mimetype', ev.target.value);
164
+ });
165
+ e.mimetype.wrapper.append(sel);
166
+ }
167
+
168
+ e.buttons = D.addClass(D.div(), 'buttons');
169
+ { /* Preview/submit buttons... */
170
+ e.button.preview = D.attr(
171
+ D.button("Preview", e=>this.#preview()),
172
+ 'title',
173
+ 'Preview your edits.'
174
+ );
175
+ e.button.submit = D.attr(
176
+ D.button("Submit"),
177
+ 'title',
178
+ 'Save any edits to the server. Not permitted until Preview has been used.'
179
+ );
180
+ if( this.#draft && !opt.hideStash ){
181
+ e.button.stash = D.attr(
182
+ D.button(
183
+ "Stash", e=>this.close()
184
+ /* This could be called Close, but that would semantically
185
+ collide with the Close [this post] button. All "Stash"
186
+ does is close the widget. */
187
+ ),
188
+ 'title', "Close this editor and stash any edits locally."
189
+ );
190
+ }
191
+ if( opt.ondiscard instanceof Function ){
192
+ e.button.discard = D.attr(
193
+ D.button('Discard'),
194
+ 'title',
195
+ 'Close the editor and discard all local edits.'
196
+ );
197
+ }
198
+ if( 1 ){
199
+ F.confirmer(e.button.submit, {
200
+ confirmText: "Confirm submit...",
201
+ onconfirm: ()=>this.#submit()
202
+ });
203
+ if( e.button.discard ){
204
+ F.confirmer(e.button.discard, {
205
+ confirmText: "Really discard?",
206
+ onconfirm: ()=>this.discard()
207
+ });
208
+ }
209
+ }else{
210
+ e.button.submit.addEventListener('click', ()=>this.#submit());
211
+ if( e.button.discard ){
212
+ e.button.submit.addEventListener('click', ()=>this.discard());
213
+ }
214
+ }
215
+ e.button.submit.setAttribute('disabled', '');
216
+ wrapper.append(e.buttons);
217
+
218
+ e.error = D.addClass(D.div(), 'error', 'hidden');
219
+ wrapper.append(e.error);
220
+ e.error.addEventListener('dblclick',()=>this.reportError());
221
+ }
222
+
223
+ const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
224
+ { /* Main tabs... */
225
+ e.tabs = D.attr(
226
+ D.addClass(D.div(), 'tab-container'),
227
+ 'id', idPrefix+'-tabs'
228
+ );
229
+ this.#tabs = new F.TabManager(e.tabs);
230
+ this.#tabs.addEventListener('before-switch-to', (ev)=>{
231
+ //console.debug("Switching to tab",ev.detail);
232
+ switch( (this.#activeTab = ev.detail) ){
233
+ case e.preview:
234
+ this.#e.button.preview.click();
235
+ break;
236
+ case e.help:
237
+ if( e.help.$needsInit ){
238
+ delete e.help.$needsInit;
239
+ this.#initHelpTab();
240
+ }
241
+ break;
242
+ case e.tabAttach:
243
+ if( !this.#att ) this.#initAttacherTab();
244
+ break;
245
+ }
246
+ });
247
+ wrapper.append( e.tabs );
248
+
249
+ e.tabEdit = D.div();
250
+ e.tabEdit.classList.add('editor-wrapper');
251
+ e.editor = D.attr(
252
+ D.addClass(D.textarea(), 'editor'),
253
+ 'placeholder',
254
+ 'Your message to other forum-goers...'
255
+ );
256
+ e.tabEdit.append(e.editor);
257
+ e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo)
258
+ ? 'Edit' : 'Reply';
259
+ this.#tabs.addTab( e.tabEdit );
260
+ this.#tabs.switchToTab( e.tabEdit );
261
+ if( this.#draft ){
262
+ this.editorContent = this.#draft.content || opt.edit?.W || '';
263
+ e.editor.addEventListener(
264
+ 'blur', ()=>{
265
+ this.#draft.content = this.editorContent;
266
+ this.#storeDraft();
267
+ }
268
+ );
269
+ }else if( opt.edit?.W ){
270
+ this.editorContent = opt.artifact.W;
271
+ }
272
+ e.preview = D.addClass(D.div(), 'preview');
273
+ e.preview.dataset.tabLabel = 'Preview';
274
+ this.#toDisable.push(e.button.preview);
275
+ this.#tabs.addTab( e.preview );
276
+ }
277
+
278
+ if( F.user.enableDebug ){
279
+ e.debug = D.addClass(D.div(), 'debug');
280
+ e.debug.dataset.tabLabel = 'Debug';
281
+ e.debug.setAttribute('id', idPrefix+'-debug');
282
+ for(const [k,v] of Object.entries({
283
+ dryrun: 'Dry run',
284
+ domod: 'Require moderation approval',
285
+ //showqp: 'Show query parameters',
286
+ fpsilent: 'Do not send notification emails'
287
+ })){
288
+ const lbl = D.label(false, v);
289
+ lbl.prepend(D.checkbox(k));
290
+ e.debug.append(lbl);
291
+ }
292
+ this.#tabs.addTab(e.debug);
293
+ }
294
+ e.buttons.append(e.mimetype.wrapper);
295
+
296
+ if( opt.edit
297
+ && !opt.inReplyTo
298
+ && F.config.forumStatuses?.length>0 ){
299
+ const sel = e.status = D.select();
300
+ sel.setAttribute('title', 'The status tag value for this post.');
301
+ D.option(sel, "", "- Status -").disabled = true;
302
+ for( const status of F.config.forumStatuses ){
303
+ D.option(sel, status.value, status.label);
304
+ }
305
+ e.buttons.append(sel);
306
+ if( opt.status ){
307
+ sel.value = opt.status;
308
+ }else if( this.#draft ){
309
+ if( this.#draft.status ){
310
+ sel.value = this.#draft.status;
311
+ }else{
312
+ this.#draft.status = sel.value = F.config.forumStatuses[0].value;
313
+ }
314
+ sel.addEventListener('change',ev=>{
315
+ const v = sel.value;
316
+ if( this.#draft.status !== v ){
317
+ this.#draft.status = v;
318
+ this.#storeDraft();
319
+ }
320
+ });
321
+ }
322
+ }/*e.status*/
323
+
324
+ if( F.user.mayAttachForum ){
325
+ //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
326
+ e.tabAttach = D.div();
327
+ e.tabAttach.setAttribute('id', idPrefix+'-attach');
328
+ e.tabAttach.dataset.tabLabel = 'Attachments';
329
+ this.#tabs.addTab(e.tabAttach);
330
+ /* Reminder: we don't currently have a way to disable/enable
331
+ an Attacher's controls during ajax traffic. */
332
+ }
333
+ e.buttons.append(e.button.preview, e.button.submit);
334
+ if( e.button.stash ){
335
+ e.buttons.append(e.button.stash);
336
+ this.#toDisable.push(e.button.stash);
337
+ }
338
+ if( e.button.discard ){
339
+ e.buttons.append(e.button.discard);
340
+ this.#toDisable.push(e.button.discard);
341
+ }
342
+
343
+ e.help = D.attr(D.div(), 'id', idPrefix+'-help');
344
+ e.help.$needsInit = true;
345
+ e.help.dataset.tabLabel = 'Help';
346
+ this.#tabs.addTab(e.help);
347
+
348
+ if( opt.hiddenFields ){
349
+ this.addHiddenFields( opt.hiddenFields );
350
+ delete opt.hiddenFields;
351
+ }
352
+
353
+ { /* Shift-enter pieces... */
354
+ const eCb = D.checkbox(1);
355
+ const eLbl = D.label();
356
+ const eHelp = D.append(
357
+ D.span(), [
358
+ 'When checked, shift-enter will toggle between preview ',
359
+ 'and edit modes, which is generally useful but some ',
360
+ 'software keyboards misinteract with it. If the preview ',
361
+ 'starts when tapping Enter, turn this setting off.'
362
+ ].join('')
363
+ );
364
+ eCb.checked = F.storage.getBool(
365
+ 'edit-shift-enter-preview',
366
+ true
367
+ /* Maintenance reminder: this setting is shared across
368
+ several apps, like /chat, /wikiedit, and /fileedit. */
369
+ );
370
+ eCb.addEventListener('change', (ev)=>{
371
+ F.storage.set('edit-shift-enter-preview', eCb.checked);
372
+ });
373
+ F.helpButtonlets.setup(eHelp);
374
+ eLbl.append("Shift-enter toggles preview?", eCb, eHelp);
375
+ e.tabEdit.append(eLbl);
376
+ const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode;
377
+ e.editor.addEventListener('keydown',(ev)=>{
378
+ /**
379
+ If eCb.checked is true, a keyboard combo of shift-enter
380
+ (from the editor) toggles between preview and edit modes.
381
+ This is normally desired but at least one software
382
+ keyboard is known to misinteract with this, treating an
383
+ Enter after automatically-capitalized letters as a
384
+ shift-enter:
385
+
386
+ https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8
387
+ */
388
+ if(!isShiftEnter(ev)) return;
389
+ ev.preventDefault();
390
+ ev.stopPropagation();
391
+ e.editor.blur(/*force draft update if needed*/);
392
+ this.#tabs.switchToTab(e.preview);
393
+ }, false);
394
+ // If we're in the preview tab, have ctrl-enter switch back to the editor.
395
+ document.body.addEventListener('keydown',(ev)=>{
396
+ if(!isShiftEnter(ev)) return;
397
+ if(this.#activeTab !== e.tabEdit){
398
+ ev.preventDefault();
399
+ ev.stopPropagation();
400
+ this.#tabs.switchToTab(e.tabEdit);
401
+ e.editor.focus(/*slow as molasses for long docs, as focus()
402
+ forces a document reflow. */);
403
+ return false;
404
+ }
405
+ }, true);
406
+ }/*shift-enter preview bits*/
407
+
408
+ if(0){ /* Needs to be optional */
409
+ const elemsToToggle = document.body.querySelectorAll(
410
+ ':scope > header, :scope > nav'
411
+ );
412
+ e.button.toggleHeader =
413
+ D.button('Toggle header', e=>{
414
+ for(const et of elemsToToggle){
415
+ et.classList.toggle('hidden');
416
+ }
417
+ });
418
+ e.buttons.append(e.button.toggleHeader);
419
+ }
420
+
421
+ {
422
+ const eLbl = D.label(false, "Posting as "+F.user.name)
423
+ eLbl.classList.add('logged-in-as');
424
+ e.buttons.append(eLbl);
425
+ }
426
+
427
+ }/*constructor*/
428
+
429
+ /*
430
+ ** Removes this object from the DOM. It has no side effects if
431
+ ** it's not in the DOM.
432
+ */
433
+ close(){
434
+ const e = this.#e.widget;
435
+ if( e?.parentNode ){
436
+ if( this.#opt.onclose instanceof Function ){
437
+ try{this.#opt.onclose();}
438
+ catch(e){
439
+ console.error("ForumPostEditor.onclose() threw:",e);
440
+ }
441
+ }
442
+ //console.debug("FPE discarding", this);
443
+ e.classList.add('animate-exit');
444
+ e.addEventListener('animationend', ()=>e.remove(), {once: true});
445
+ dummyPadding.refs.delete(this);
446
+ if( 0===dummyPadding.refs.size ){
447
+ dummyPadding.remove();
448
+ }
449
+ }
450
+ }
451
+
452
+ /*
453
+ ** Discards any draft edits then calls close(). If an ondiscard
454
+ ** callback was provided to the constructor then it is called
455
+ ** before the drafts are cleared and any exceptions it throws are
456
+ ** ignored (but may be logged).
457
+ */
458
+ discard(){
459
+ if( this.#opt.ondiscard instanceof Function ){
460
+ try{this.#opt.ondiscard(this);}
461
+ catch(e){
462
+ console.error("ForumPostEditor.ondiscard() threw:",e);
463
+ }
464
+ }
465
+ this.#clearDraft();
466
+ this.close();
467
+ }
468
+
469
+ /** This widget's top-most DOM element. */
470
+ get widget(){
471
+ if( !dummyPadding.parentElement ){
472
+ document.body.append(dummyPadding);
473
+ }
474
+ dummyPadding.refs.add(this);
475
+ return this.#e.widget;
476
+ }
477
+
478
+ get editorContent(){
479
+ /* We wrap access to the editor's contents in a getter/setter so
480
+ that we can eventually add optional use of a contenteditable
481
+ edit field, as those are generally more comfortable. The code
482
+ for that is in fossil.page.chat.js. */
483
+ return this.#e.editor.value;
484
+ }
485
+
486
+ set editorContent(v){
487
+ this.#e.editor.value = v;
488
+ }
489
+
490
+ /**
491
+ Reports an error by appending each argument to the error widget
492
+ and unhiding it. If passed no arugments, it clears and hides
493
+ the error widget.
494
+ */
495
+ reportError(...msg){
496
+ const e = this.#e.error;
497
+ D.clearElement(e);
498
+ if( msg.length ){
499
+ console.error('ForumPostEditor:',...msg);
500
+ e.classList.remove('hidden');
501
+ e.append(
502
+ ...msg, D.br(),
503
+ D.button("Clear", ()=>this.reportError())
504
+ /* Looks horrid in the Blitz skin */
505
+ );
506
+ }else{
507
+ e.classList.add('hidden');
508
+ }
509
+ }
510
+
511
+ /**
512
+ Adds a list of input[type=hidden] form fields to this object,
513
+ imported from the server-generated HTML. This is used for
514
+ collecting, e.g., the CSRF token and an initial page title.
515
+ */
516
+ addHiddenFields(list){
517
+ this.#extraFields ??= [];
518
+ for( const f of list ){
519
+ if( !f ) continue;
520
+ if( 'title'===f.name && this.#e.title ){
521
+ if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
522
+ this.#e.title.value = f.value;
523
+ }
524
+ }else{
525
+ this.#extraFields.push(f);
526
+ }
527
+ }
528
+ }
529
+
530
+ get mimetype(){
531
+ return this.#e.mimetype.select.value;
532
+ }
533
+
534
+ get title(){
535
+ return this.#e.title?.value || this.#opt.edit?.H;
536
+ }
537
+
538
+ #initHelpTab(){
539
+ const eh = this.#e.help;
540
+ const list = D.ul();
541
+ D.append(
542
+ D.li(list),
543
+ D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
544
+ 'target', '_new')
545
+ );
546
+ D.append(
547
+ D.li(list),
548
+ "WARNING: draft edits are keyed on the ID of the message they ",
549
+ "are editing or responding to. Attempting to edit or reply to ",
550
+ "the same post from multiple tabs will cause the most-recently-edited ",
551
+ "one to overwrite the draft slot for that post. In browsers which support ",
552
+ "Web Locks, a second attempt to edit or reply to a post will be blocked ",
553
+ "and an error will be shown explaining the problem."
554
+ );
555
+ if( this.#e.status ){
556
+ D.append(
557
+ D.li(list),
558
+ "Tip: changing just the status in the editor will change only that, ",
559
+ "not a whole new (but unedited) copy of the post."
560
+ );
561
+ }
562
+ eh.append(list);
563
+ }
564
+
565
+ #initAttacherTab(){
566
+ this.#att = new F.Attacher({
567
+ reverse: true
568
+ });
569
+ if( this.#opt.edit ){
570
+ const eNote = D.append(
571
+ D.div(),
572
+ "Tip: attachments can be added to posts without editing them ",
573
+ "by visiting ",
574
+ D.attr(
575
+ D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'),
576
+ 'target',
577
+ '_new'
578
+ ),
579
+ ".",
580
+ );
581
+ this.#e.tabAttach.append(eNote);
582
+ }
583
+ this.#e.tabAttach.append(this.#att.widget);
584
+ }
585
+
586
+ #newFormData(addThisContent){
587
+ const fd = new FormData;
588
+ for(const f of this.#extraFields){
589
+ fd.append(f.name, f.value);
590
+ }
591
+ let v;
592
+ if( this.#opt.inReplyTo ){
593
+ fd.append( 'firt', this.#opt.inReplyTo );
594
+ }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){
595
+ fd.append('title', v);
596
+ }
597
+ fd.append('mimetype', this.mimetype);
598
+ fd.append('content', addThisContent || this.editorContent.trim());
599
+ return fd;
600
+ }
601
+
602
+ async #fetchPreview(content){
603
+ /* TODO: fetch preview */
604
+ const e = this.#e;
605
+ const fd = /*no: this.#newFormData(content); */
606
+ new FormData;
607
+ let ext;
608
+ switch(this.mimetype){
609
+ case 'text/x-markdown': ext = 'md'; break;
610
+ case 'text/x-fossil-wiki': ext = 'wiki'; break;
611
+ default: ext = 'txt'; break;
612
+ }
613
+ fd.append('filename', 'x.'+ext/*for mimetype determination*/);
614
+ fd.append('content', this.editorContent.trim());
615
+ return window
616
+ .fetch(F.repoUrl('ajax/preview-text'),{
617
+ method: 'POST',
618
+ body: fd
619
+ })
620
+ .then(r=>r.text())
621
+ .then(t=>{
622
+ if( /^\{.*}$/.test(t) ){
623
+ const o = JSON.parse(t);
624
+ throw new Error(o.error);
625
+ }
626
+ return t;
627
+ });
628
+ }
629
+
630
+ #setPreviewContent(rawHtml){
631
+ /**
632
+ Append the new content then remove the old, to help reduce
633
+ jumping-around of the UI if the preview is cleared then
634
+ repopulated.
635
+ */
636
+ const preview = this.#e.preview;
637
+ const childs = [...preview.childNodes];
638
+ D.parseHtml(preview, rawHtml);
639
+ D.remove(childs);
640
+ //preview.style.removeProperty('height');
641
+ if(F.pikchr && 'text/x-markdown'===this.mimetype){
642
+ F.pikchr.addSrcView(
643
+ preview.querySelectorAll('svg.pikchr')
644
+ );
645
+ }
646
+ }
647
+
648
+ async #preview(){
649
+ if( this.#isWaiting ) return;
650
+ const e = this.#e;
651
+ if( e.preview !== this.#activeTab ){
652
+ this.#tabs.switchToTab(e.preview);
653
+ /* Will recurse into here */
654
+ return;
655
+ }
656
+ const content = this.editorContent.trim();
657
+ //console.debug("content to preview", content);
658
+ if( !content ){
659
+ return;
660
+ }
661
+ if( 0
662
+ && !e.preview.firstElementChild ){
663
+ /* On an initial first preview, inherit the editor's height to
664
+ reduce jumping-around of the UI. */
665
+ if( 0 /* does not work: height of the editor is "auto" */ ){
666
+ const c = window.getComputedStyle(e.editor/*tabEdit*/);
667
+ e.preview.style.height = c.height;
668
+ }else{
669
+ e.preview.style.height = '20em';
670
+ }
671
+ }
672
+ this.#isWaiting = true;
673
+ D.disable(this.#toDisable, e.button.submit);
674
+ this.#fetchPreview(content)
675
+ .then((c)=>{
676
+ this.#setPreviewContent(c);
677
+ D.enable(e.button.submit);
678
+ })
679
+ .catch(err=>{
680
+ e.preview.textContent = "Error fetching preview: "+err.message;
681
+ console.error("Error fetching preview:",err);
682
+ this.reportError(err.message);
683
+ })
684
+ .finally(()=>{
685
+ this.#isWaiting = false;
686
+ D.enable(this.#toDisable);
687
+ });
688
+ }
689
+
690
+ #validate(tgt){
691
+ if( this.#e.title ){
692
+ const v = this.#e.title.value.trim();
693
+ if( !v ){
694
+ this.reportError("A non-empty title is required.");
695
+ return;
696
+ }
697
+ }
698
+ return true;
699
+ }
700
+
701
+ #submit(){
702
+ if( this.#isWaiting ) return;
703
+ if( !this.#validate() ) return;
704
+ this.#isWaiting = true;
705
+ const e = this.#e;
706
+ D.disable(e.button.submit);
707
+ const fd = this.#newFormData();
708
+ if( this.#e.status ){
709
+ /* Send the status only if it was modified, otherwise we may
710
+ add a superfluous tag. */
711
+ const v = this.#e.status.value;
712
+ if( this.#e.status.dataset.originalValue !== v ){
713
+ fd.append("status", v);
714
+ }
715
+ }
716
+ if( e.debug ){
717
+ e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{
718
+ if( cb.checked ){
719
+ fd.append(cb.value, 1);
720
+ //console.debug("Forum post debug option:",cb);
721
+ }
722
+ });
723
+ }
724
+ if( this.#att ){
725
+ this.#att.populateFormData(fd);
726
+ }
727
+ //console.warn("Ready to submit",fd);
728
+ if( 0 ){
729
+ this.#isWaiting = false;
730
+ return;
731
+ }
732
+ const resp = window.fetch(F.repoUrl('forumajax_save'), {
733
+ method: 'POST',
734
+ body: fd
735
+ }).then(r=>r.json())
736
+ .then(j=>{
737
+ j = F.nu(j);
738
+ console.debug("forum post editor response:",j);
739
+ if( j.error ){
740
+ throw new Error(j.error);
741
+ }else if( j.message ){
742
+ /* This is only for use in debugging during
743
+ * development. */
744
+ this.reportError(j.message);
745
+ return;
746
+ }
747
+ if( 1 ){
748
+ this.#clearDraft();
749
+ if( this.#opt.onsubmit instanceof Function ){
750
+ try{this.#opt.onsubmit(this, j);}
751
+ catch(e){
752
+ console.error("ForumPostEditor.onsubmit() threw: ", e);
753
+ }
754
+ }
755
+ /*
756
+ if( this.#opt.edit?.uuid === j.uuid ) then we know the
757
+ content did not change, but it's possible that attachments
758
+ and/or a status tag did. Ergo, we need to unconditionally
759
+ reload to render those changes (if any). The other option
760
+ is to tell the user "nothing changed" and leave them in
761
+ the editor, but that could be a lie because we don't know
762
+ if any attachments or tags were changed.
763
+ */
764
+ else if( 0 ){
765
+ if( this.#opt.edit.uuid === j.uuid
766
+ && !j.statusModified && 0===j.attachedCount ){
767
+ this.reportError("No changes made.");
768
+ }else{
769
+ window.location = F.repoUrl('forumpost/'+j.uuid);
770
+ setTimeout(()=>this.close(), 500/*just in case not redirected*/);
771
+ }
772
+ }
773
+ }else{
774
+ this.reportError(
775
+ "Saving worked but we're ignoring it and staying here."
776
+ );
777
+ }
778
+ })
779
+ .catch((e)=>this.reportError(e.message))
780
+ .finally(()=>this.#isWaiting = false);
781
+ }
782
+
783
+ #storeDraft(){
784
+ if( this.#draft ){
785
+ this.#draft.mtime = Date.now();
786
+ F.storage.setJSON(this.#opt.draftKey, this.#draft);
787
+ }
788
+ }
789
+
790
+ /** Clears any persistent draft state. Does not clear the UI
791
+ widgets. */
792
+ #clearDraft(){
793
+ if( this.#draft ){
794
+ F.storage.remove(this.#opt.draftKey);
795
+ this.#draft = F.nu();
796
+ }
797
+ }
798
+
799
+ /**
800
+ Looks for editing draft keys matching either a fixed key or a
801
+ regex, and removes each matching one which is older than the
802
+ given number of days. Pass days=0 to purge all entries
803
+ immediately.
804
+ */
805
+ static purgeOldDrafts(key, days=10){
806
+ const age = (3600 * 24 * days) * 1000/*ms*/;
807
+ const now = Date.now();
808
+ const check = (k)=>{
809
+ const o = F.storage.getJSON(k);
810
+ if( o && o.mtime && (!days || (o.mtime+age <= now)) ){
811
+ F.storage.remove(k);
812
+ }
813
+ };
814
+ if( key instanceof RegExp ){
815
+ for(const k of F.storage.keys(false).filter(v=>key.test(v))){
816
+ check(k);
817
+ }
818
+ }else{
819
+ check(key);
820
+ }
821
+ }
822
+
823
+ async #fetchPost(){
824
+ /*
825
+ TODO: when editing an existing post, fetch the raw body of the
826
+ post and populate this.e.
827
+ */
828
+ }
829
+ }/*ForumPostEditor*/;
830
+ F.ForumPostEditor = ForumPostEditor;
831
+
7832
/**
8833
When the page is loaded, this handler does the following:
9834
10
- - Installs expand/collapse UI elements on "long" posts and collapses
835
+ 1. Installs expand/collapse UI elements on "long" posts and collapses
11836
them.
12837
13
- - Any pikchr-generated SVGs get a source-toggle button added to them
838
+ 2. Any pikchr-generated SVGs get a source-toggle button added to them
14839
which activates when the mouse is over the image or it is tapped.
840
+
841
+ 3. Plugs in a new edit/reply widget to forum posts.
15842
16843
This is a harmless no-op if the current page has neither forum
17
- post constructs for (1) nor any pikchr images for (2), nor will
18
- NOT running this code cause any breakage for clients with no JS
19
- support: this is all "nice-to-have", not required functionality.
844
+ post constructs for (1) and (3) nor any pikchr images for (2),
845
+ nor will NOT running this code cause any breakage for clients
846
+ with no JS support: this is all "nice-to-have", not required
847
+ functionality.
20848
*/
21849
F.onPageLoad(function(){
22850
const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
23851
/* Returns an event handler which implements the post expand/collapse toggle
24852
on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
105933
const eStatus = document.querySelector(
106934
'form div.submenu select.submenuctrl[name="status"]'
107935
);
108936
if( eStatus ){
109937
/* Main /forum list. Remove the 'x' form element when eStatus
110
- ** changes, to avoid propagating x when changing the filter. */
938
+ changes, to avoid propagating x when changing the filter.
939
+ The problem this solves: we're browsed to page 3 of status X.
940
+ We change the status filter selection to Y. We're redirected
941
+ to page x, but Y only has 2 posts with that status, so we see
942
+ an empty list. When changing the filter, we need to ensure
943
+ that we start back and that beginning. */
111944
const pForm = eStatus.parentElement?.parentElement;
112945
if( pForm ){
113946
eStatus.addEventListener('change', ()=>{
114
- pForm.querySelector('input[type="hidden"][name="x"]')?.remove();
947
+ pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.();
115948
}, true);
116949
}
117950
}else{
118951
/* One of the single-post edit/view pages. Handle various UI
119952
controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
126959
return;
127960
}
128961
form.dataset.submitted = '1';
129962
/** If the user is left waiting "a long time," disable the
130963
resubmit protection. If we don't do this and they tap the
131
- browser's cancel button while waiting, they'll be stuck with
132
- an unsubmittable form. */
964
+ browser's cancel button while waiting, they'll be stuck
965
+ with an unsubmittable form. It can apparently also happen,
966
+ via browser-back, that the form gets left in a submitted
967
+ state. */
133968
setTimeout(()=>{delete form.dataset.submitted}, 7000);
134969
return;
135970
};
971
+
136972
document.querySelectorAll("form").forEach(function(form){
973
+ /* Set up controls for closing posts and setting thread
974
+ status. */
137975
form.addEventListener('submit', formSubmitted);
138976
form
139977
.querySelectorAll("input.action-close, input.action-reopen")
140978
.forEach(function(e){
141979
e.classList.remove('hidden');
@@ -149,11 +987,13 @@
149987
form
150988
.querySelectorAll("input[type='button'].action-status")
151989
.forEach(function(btn){
152990
btn.classList.remove('hidden');
153991
const sel = btn.previousElementSibling;
154
- const updateAble = ()=>{
992
+ const updateButton = ()=>{
993
+ /* Enable btn only when the status has been locally
994
+ modified. */
155995
if( sel.dataset.initialValue ){
156996
if( sel.dataset.initialValue===sel.value ){
157997
btn.setAttribute('disabled','');
158998
}else{
159999
btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
1641004
}else{
1651005
btn.removeAttribute('disabled');
1661006
}
1671007
}
1681008
};
169
- sel.addEventListener('change', updateAble, true);
170
- updateAble();
1009
+ sel.addEventListener('change', updateButton, true);
1010
+ updateButton();
1711011
F.confirmer(btn, {
1721012
confirmText: "Confirm status change",
1731013
onconfirm: ()=>form.submit()
1741014
});
1751015
});
1761016
});
1771017
}
1781018
1019
+ /* Apply page-specific tweaks for ForumPostEditor instance fpe
1020
+ then plug it into the UI at the end of ePost. */
1021
+ const initFPEWidget = (fpe, ePost)=>{
1022
+ ePost.eUnhideThenWhenDone = [
1023
+ /* List of elements to hide while editing/replying and reveal
1024
+ when discarding or saving. */
1025
+ ];
1026
+ for( const ee of ePost.querySelectorAll(
1027
+ '.forumpost-single-controls, fieldset.forum-status-selection'
1028
+ ) ){
1029
+ ee.hidden = true;
1030
+ ePost.eUnhideThenWhenDone.push(ee);
1031
+ }
1032
+ const w = fpe.widget;
1033
+ w.classList.add('animate-entrance');
1034
+ ePost.append(w);
1035
+ requestAnimationFrame(() => {
1036
+ w.scrollIntoView({
1037
+ behavior: 'smooth',
1038
+ block: 'nearest',
1039
+ inline: 'nearest'
1040
+ });
1041
+ });
1042
+ };
1043
+
1044
+ const plugInEditor =
1045
+ (new URL(window.location).searchParams).get('nojs')===null;
1046
+
1047
+ const eForumNew = (
1048
+ plugInEditor
1049
+ && (
1050
+ document.body.classList.contains('cpage-forumnew')
1051
+ || document.body.classList.contains('cpage-forume1')
1052
+ ))
1053
+ ? document.body.querySelector('#forumnew-placeholder')
1054
+ : null;
1055
+ if( plugInEditor && eForumNew ){
1056
+ /* /forumnew and /forume1 */
1057
+ const fpe = new F.ForumPostEditor({
1058
+ draftKey: 'draft-forumnew',
1059
+ hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
1060
+ hideStash: true,
1061
+ ondiscard: ()=>{
1062
+ window.location = F.repoUrl('forum');
1063
+ },
1064
+ onsubmit: (fpe, response)=>{
1065
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1066
+ }
1067
+ });
1068
+ eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
1069
+ eForumNew.remove();
1070
+ fossil.page.fpe = fpe /* for testing via the console */;
1071
+ }/*eForumNew*/
1072
+ else if( plugInEditor
1073
+ && (document.body.classList.contains('cpage-forumpost')
1074
+ || document.body.classList.contains('cpage-forumthread')) ){
1075
+ /* /forumpost and /forumthread. Take over the Edit/Reply buttons
1076
+ to use a ForumPostEditor. */
1077
+
1078
+ const fetchPost = async (fpid)=>{
1079
+ return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
1080
+ .then(r=>r.json())
1081
+ .then(j=>{
1082
+ j = F.nu(j);
1083
+ if( j.error ) throw new Error(j.error);
1084
+ return j;
1085
+ });
1086
+ };
1087
+
1088
+ const makeDraftKey = (prefix,uuid)=>{
1089
+ return prefix+'-'+uuid.substr(0,12);
1090
+ };
1091
+
1092
+ /**
1093
+ Perform some init common to both Reply and Edit. ePost = the
1094
+ forum post DOM element. eButton = the Reply or Edit
1095
+ button.
1096
+ */
1097
+ const setupEditReplyElement = (ePost, eButton)=>{
1098
+ /* Forum posts are indented in the main forum view to
1099
+ represent their place in the hierarchy. In order to gain
1100
+ some screen space, we shift the post to the left margin and
1101
+ arrange to shift it back when the editor is closed. We also
1102
+ record the original button label so that it can be
1103
+ restored on close. */
1104
+ ePost.dataset.originalMarginLeft = ePost.style.marginLeft;
1105
+ ePost.style.marginLeft = 'initial';
1106
+ eButton.dataset.originalLabel = eButton.innerText;
1107
+ };
1108
+
1109
+ /** Undoes the damage done by setupEditReplyElement(). */
1110
+ const restoreEditReplyElement = (ePost, eButton)=>{
1111
+ if( ePost.dataset.originalMarginLeft ){
1112
+ ePost.style.marginLeft = ePost.dataset.originalMarginLeft;
1113
+ delete ePost.dataset.originalMarginLeft;
1114
+ }
1115
+ if( eButton.dataset.originalLabel ){
1116
+ eButton.innerText = eButton.dataset.originalLabel;
1117
+ delete eButton.dataset.originalLabel;
1118
+ }
1119
+ for(const ee of (ePost.eUnhideThenWhenDone || [])){
1120
+ ee.removeAttribute('hidden');
1121
+ }
1122
+ ePost.eUnhideThenWhenDone = undefined;
1123
+ };
1124
+
1125
+ /**
1126
+ Reports an error regarding the forum post element
1127
+ ePost, appending each entry in msg to a wrapper
1128
+ element with the class
1129
+ */
1130
+ const reportFPEError = (ePost,...msg)=>{
1131
+ const e = D.addClass(D.p(), 'error');
1132
+ e.append(
1133
+ ...msg,
1134
+ D.br(),
1135
+ D.button("Clear error", ()=>e.remove())
1136
+ );
1137
+ ePost.append(e);
1138
+ };
1139
+
1140
+ /**
1141
+ Plug in an editor widget representing a reply to a post.
1142
+ form = a (.forum-post-single-controls > form) element. The
1143
+ final 3 arguments are as documented for
1144
+ setupEditReplyElement().
1145
+ */
1146
+ const replyClicked = async (form, ePost, eBtnReply)=>{
1147
+ const fpid = ePost.dataset.fpid;
1148
+ const fEditHead = ePost.dataset.fedithead;
1149
+ const draftKey = makeDraftKey(
1150
+ 'draft-reply', fEditHead
1151
+ /* The problem with firt as a key is that firt is not
1152
+ necessarily the root edit of that post, which is what we
1153
+ really want as a draft key so that the draft does not
1154
+ disappear if firt is later edited (giving us a new firt
1155
+ value here). */
1156
+ || fpid
1157
+ );
1158
+ let releaseLock;
1159
+ if( window.navigator.locks ){
1160
+ releaseLock = await new Promise((resolve)=>{
1161
+ window.navigator.locks.request(
1162
+ 'fossil-'+draftKey,
1163
+ {ifAvailable: true},
1164
+ async (lock) => {
1165
+ if( !lock ){
1166
+ /*lock contention*/
1167
+ resolve(null);
1168
+ return;
1169
+ }
1170
+ let release;
1171
+ const lockReleased = new Promise(res=>release=res);
1172
+ resolve(release);
1173
+ await lockReleased/*hold the lock open*/;
1174
+ });
1175
+ });
1176
+ if( !releaseLock ){
1177
+ reportFPEError(
1178
+ ePost,
1179
+ "This post is actively being replied to ",
1180
+ "in another tab. To avoid losing edits, ",
1181
+ "it cannot be opened here until the locking ",
1182
+ "tab is closed."
1183
+ );
1184
+ return;
1185
+ }
1186
+ }
1187
+
1188
+ setupEditReplyElement(ePost, eBtnReply);
1189
+ eBtnReply.innerText = "Replying...";
1190
+ const ondone = (fpe, response)=>{
1191
+ /* onsubmit() and ondiscard() callback */
1192
+ restoreEditReplyElement(ePost, eBtnReply);
1193
+ //console.debug("ondiscard/onsubmit", fpe, artifact);
1194
+ if( response/*onsubmit()*/ ){
1195
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1196
+ setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1197
+ }else{/*ondiscard() or onclose()*/
1198
+ }
1199
+ };
1200
+ const fpe = new F.ForumPostEditor(F.nu({
1201
+ hiddenFields: form.querySelectorAll(
1202
+ 'input[type=hidden][name=csrf]'
1203
+ /* Do not inherit the fpid field, else this will become
1204
+ an edit to that post rather than a response. */
1205
+ ),
1206
+ ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1207
+ onsubmit: ondone,
1208
+ onclose: ()=>{
1209
+ if( releaseLock ){
1210
+ releaseLock();
1211
+ releaseLock = null;
1212
+ }
1213
+ ondone();
1214
+ },
1215
+ inReplyTo: fpid,
1216
+ draftKey
1217
+ }));
1218
+ initFPEWidget(fpe, ePost);
1219
+ }/*replyClicked()*/;
1220
+
1221
+ /**
1222
+ Plug in an editor widget representing an edit to a post.
1223
+ form = a (.forum-post-single-controls > form) element. The
1224
+ final 3 arguments are as documented for
1225
+ setupEditReplyElement().
1226
+ */
1227
+ const editClicked = async (form, ePost, eBtnEdit)=>{
1228
+ const fpid = ePost.dataset.fpid;
1229
+ const firt = ePost.dataset.firt;
1230
+ const fEditHead = ePost.dataset.fedithead;
1231
+ const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1232
+ let releaseLock;
1233
+ if( navigator.locks ){
1234
+ releaseLock = await new Promise((resolve) => {
1235
+ navigator.locks.request(
1236
+ 'fossil-'+draftKey,
1237
+ {ifAvailable: true},
1238
+ async (lock)=>{
1239
+ if( !lock ){
1240
+ resolve(null);
1241
+ return;
1242
+ }
1243
+ let release;
1244
+ const lockReleased = new Promise(res=>release=res);
1245
+ resolve(release);
1246
+ await lockReleased;
1247
+ });
1248
+ });
1249
+
1250
+ if( !releaseLock ){
1251
+ reportFPEError(
1252
+ ePost,
1253
+ "This post is actively being edited ",
1254
+ "in another tab. To avoid losing edits, ",
1255
+ "it cannot be opened here until the locking ",
1256
+ "tab is closed."
1257
+ );
1258
+ return;
1259
+ }
1260
+ }
1261
+ setupEditReplyElement(ePost, eBtnEdit);
1262
+ eBtnEdit.innerText = "Editing...";
1263
+ fetchPost(fpid)
1264
+ .then(artifact=>{
1265
+ const ondone = (fpe, response)=>{
1266
+ /* onsubmit() and ondiscard() callback */
1267
+ if( response/*onsubmit()*/ ){
1268
+ if( fpid === response.uuid
1269
+ && !response.statusModified
1270
+ && 0===response.attachedCount ){
1271
+ fpe.reportError("No changes made.");
1272
+ }else{
1273
+ restoreEditReplyElement(ePost, eBtnEdit);
1274
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1275
+ setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1276
+ }
1277
+ }else{
1278
+ /*ondiscard() or onclose()*/
1279
+ restoreEditReplyElement(ePost, eBtnEdit);
1280
+ }
1281
+ };
1282
+ const eStatusSelect = ePost.querySelector(
1283
+ ':scope > fieldset.forum-status-selection select[name=status]'
1284
+ );
1285
+
1286
+ const fpe = new F.ForumPostEditor(F.nu({
1287
+ hiddenFields: form.querySelectorAll('input[type=hidden]'),
1288
+ ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1289
+ onsubmit: ondone,
1290
+ onclose: ()=>{
1291
+ if(releaseLock){
1292
+ releaseLock();
1293
+ releaseLock = null;
1294
+ }
1295
+ ondone();
1296
+ },
1297
+ draftKey,
1298
+ edit: artifact,
1299
+ status: eStatusSelect?.value,
1300
+ inReplyTo: firt
1301
+ }));
1302
+ initFPEWidget(fpe, ePost);
1303
+ })
1304
+ .catch(err=>{
1305
+ if( releaseLock ){
1306
+ releaseLock();
1307
+ releaseLock = null;
1308
+ }
1309
+ restoreEditReplyElement(ePost, eBtnEdit);
1310
+ console.error("Error fetching post:", err);
1311
+ reportFPEError(ePost, "Error fetching post: ", err.message);
1312
+ });
1313
+ }/*editClicked()*/;
1314
+
1315
+ document.body.querySelectorAll(
1316
+ '.forumpost-single-controls > form'
1317
+ ).forEach(form=>{
1318
+ /* For each forum post... */
1319
+ const eThePost = form.parentElement.parentElement/*main post DOM element*/;
1320
+ if( !eThePost?.dataset?.fpid ){
1321
+ /* The server injects these dataset values. */
1322
+ console.warn("Unexpected missing fpid", eThePost);
1323
+ return;
1324
+ }
1325
+ const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{
1326
+ /* If a draft is found associated with eThePost, mark eBtn
1327
+ as a draft and set up storage event listeners to update
1328
+ the button as new drafts come and go. */
1329
+ const fpid = eThePost.dataset.fpid;
1330
+ const fEditHead = eThePost.dataset.fedithead;
1331
+ const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid);
1332
+ if( F.storage.contains(draftKey) ){
1333
+ eBtn.classList.add('draft');
1334
+ }
1335
+ F.storage.addEventListener('set', ({detail})=>{
1336
+ if( draftKey === detail.key ){
1337
+ eBtn.classList.add('draft');
1338
+ }
1339
+ });
1340
+ F.storage.addEventListener('remove', ({detail})=>{
1341
+ if( draftKey === detail.key ){
1342
+ eBtn.classList.remove('draft');
1343
+ }
1344
+ });
1345
+ };
1346
+ /* Replace the Reply and Edit buttons with ones which will activate
1347
+ a ForumPostEditor. */
1348
+ const btnReply = form.querySelector('input[type=submit][name=reply]');
1349
+ if( btnReply ){
1350
+ const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
1351
+ b.type = 'button'/*keep container form from submitting*/;
1352
+ checkButtonForDraft('draft-reply',b);
1353
+ btnReply.parentElement.insertBefore(b, btnReply);
1354
+ btnReply.remove();
1355
+ }
1356
+ const btnEdit = form.querySelector('input[type=submit][name=edit]');
1357
+ if( btnEdit ){
1358
+ const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
1359
+ b.type = 'button'/*keep container form from submitting*/;
1360
+ checkButtonForDraft('draft-forumedit',b);
1361
+ btnEdit.parentElement.insertBefore(b, btnEdit);
1362
+ btnEdit.remove();
1363
+ }
1364
+ })/*for-each form*/;
1365
+
1366
+ }/* /forumpost and /forumthread */
1367
+
1368
+ document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove());
1369
+ document.body.querySelectorAll('.initially-hidden').forEach(e=>{
1370
+ /* This is a workaround for a span.help-buttonlet which we need
1371
+ to start hidden so that it does not show up for no-JS
1372
+ clients. */
1373
+ e.classList.remove('initially-hidden');
1374
+ });
1375
+
1376
+ if( plugInEditor ){
1377
+ document.body.querySelectorAll('.remove-if-replaced').forEach(
1378
+ /* Remove remaining legacy UI elements. */ e=>e.remove()
1379
+ );
1380
+ /* Purge old drafts only every now and then. */
1381
+ const now = Date.now();
1382
+ const lastPurge = +F.storage.get('forum-drafts-last-purge', 0);
1383
+ if( now - lastPurge > (24 * 60 * 60 * 1000 /*1 day ms*/) ){
1384
+ F.storage.set('forum-drafts-last-purge', now);
1385
+ setTimeout(()=>{
1386
+ /* Don't block the UI while we're doing I/O */
1387
+ F.ForumPostEditor.purgeOldDrafts(
1388
+ /^draft-(reply|forumedit)-.*/
1389
+ /* Intentionally leaving draft-forumnew in place. */
1390
+ );
1391
+ }, 50);
1392
+ }
1393
+ }
1791394
})/*F.onPageLoad callback*/;
1801395
})(window.fossil);
1811396
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
 
 
 
 
1 (function(F/*the fossil object*/){
2 "use strict";
3 /* JS code for /forumpost and friends. Requires fossil.dom
4 and can optionally use fossil.pikchr. */
5 const P = F.page, D = F.dom;
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7 /**
8 When the page is loaded, this handler does the following:
9
10 - Installs expand/collapse UI elements on "long" posts and collapses
11 them.
12
13 - Any pikchr-generated SVGs get a source-toggle button added to them
14 which activates when the mouse is over the image or it is tapped.
 
 
15
16 This is a harmless no-op if the current page has neither forum
17 post constructs for (1) nor any pikchr images for (2), nor will
18 NOT running this code cause any breakage for clients with no JS
19 support: this is all "nice-to-have", not required functionality.
 
20 */
21 F.onPageLoad(function(){
22 const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
23 /* Returns an event handler which implements the post expand/collapse toggle
24 on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
105 const eStatus = document.querySelector(
106 'form div.submenu select.submenuctrl[name="status"]'
107 );
108 if( eStatus ){
109 /* Main /forum list. Remove the 'x' form element when eStatus
110 ** changes, to avoid propagating x when changing the filter. */
 
 
 
 
 
111 const pForm = eStatus.parentElement?.parentElement;
112 if( pForm ){
113 eStatus.addEventListener('change', ()=>{
114 pForm.querySelector('input[type="hidden"][name="x"]')?.remove();
115 }, true);
116 }
117 }else{
118 /* One of the single-post edit/view pages. Handle various UI
119 controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
126 return;
127 }
128 form.dataset.submitted = '1';
129 /** If the user is left waiting "a long time," disable the
130 resubmit protection. If we don't do this and they tap the
131 browser's cancel button while waiting, they'll be stuck with
132 an unsubmittable form. */
 
 
133 setTimeout(()=>{delete form.dataset.submitted}, 7000);
134 return;
135 };
 
136 document.querySelectorAll("form").forEach(function(form){
 
 
137 form.addEventListener('submit', formSubmitted);
138 form
139 .querySelectorAll("input.action-close, input.action-reopen")
140 .forEach(function(e){
141 e.classList.remove('hidden');
@@ -149,11 +987,13 @@
149 form
150 .querySelectorAll("input[type='button'].action-status")
151 .forEach(function(btn){
152 btn.classList.remove('hidden');
153 const sel = btn.previousElementSibling;
154 const updateAble = ()=>{
 
 
155 if( sel.dataset.initialValue ){
156 if( sel.dataset.initialValue===sel.value ){
157 btn.setAttribute('disabled','');
158 }else{
159 btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
164 }else{
165 btn.removeAttribute('disabled');
166 }
167 }
168 };
169 sel.addEventListener('change', updateAble, true);
170 updateAble();
171 F.confirmer(btn, {
172 confirmText: "Confirm status change",
173 onconfirm: ()=>form.submit()
174 });
175 });
176 });
177 }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179 })/*F.onPageLoad callback*/;
180 })(window.fossil);
181
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
1 /**
2 Code for the forum family of pages. Requires fossil.X where X is
3 (copybutton, pikchr, confirmer, attach, tabs, storage).
4 */
5 (function(F/*the fossil object*/){
6 "use strict";
7 /* JS code for /forumpost and friends. Requires fossil.dom
8 and can optionally use fossil.pikchr. */
9 const P = F.page, D = F.dom;
10
11 let idCounter = 0;
12
13 /*
14 The problem: when previewing the bottom-most post of a thread, the
15 preview widget's size changes cause the page to scroll
16 unpredictably as the bottom boundary of the page moves. A weird
17 workaround (not invented here) is to add dummy blank padding to
18 the page to allow the preview widget to grow and shrink without
19 (usually) scrolling, but whether it does so really depends on its
20 size.
21
22 We could maybe get the same effect by adding this size as
23 padding-bottom to document.body instead of as a new element.
24 */
25 const dummyPadding = D.div();
26 dummyPadding.style.height = '75em';
27 /* Keep track of ForumPostEditor instances so we can remove this
28 padding when none are active. */
29 dummyPadding.refs = new Set();
30 F.dummyPadding = dummyPadding /* only for debugging */;
31
32 /**
33 A forum post editor widget for new posts and responses.
34 */
35 class ForumPostEditor {
36 /* Options */
37 #opt;
38 /* Dom elements */
39 #e;
40 /* F.Attacher instance */
41 #att;
42 /* Is waiting on a pending remote response. */
43 #isWaiting = false;
44 /* F.TabManager */
45 #tabs;
46 /* Elements to disable while an XHR is pending. */
47 #toDisable = [];
48 /* DOM element of the current active tab. */
49 #activeTab;
50 /* Extra input[type=hidden] fields imported from fossil's
51 static page generation. */
52 #extraFields;
53 /* Persistent draft message object. */
54 #draft;
55
56 /**
57 Options:
58
59 opt.draftKey[string=undefined]: if set then this object's state
60 will be stored in fossil.storage when the relevant input fields
61 lose focus. If old state is found, the form is pre-populated
62 from it. The state is cleared on a discard() or successful
63 submit.
64
65 opt.ondiscard[=function]: if set, a Discard button is added
66 which, when activated, clears the current draft and removes
67 this object's widget from the DOM. After doing so,
68 opt.ondiscard() is called and passed this object. Exceptions
69 thrown by ondiscard() are ignored but may be logged.
70
71 opt.onsubmit[=function]: if set, this function is called
72 immediately after the post has been successfully saved, and
73 passed this object and a JSON-format response object from the
74 save request. It is generally then up to the caller to close()
75 this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
77 opt.onclose[=function]: like opt.onsubmit, this function is
78 called when this.close() is called, and passed no arguments.
79 onclose() is called before the widget is removed from the dom
80 and _does not_ fire if it is not in the DOM.
81
82 opt.hiddenFields: an optional list of input elements to
83 incorporate into the form for requests which request the
84 preview or save the post.
85
86 opt.inReplyTo=uuid: if this is a response to a post, this
87 is the full forum post uuid of the being-replied-to post.
88
89 opt.edit=artifactObject: if this is an edit of an existing
90 post, this is the full JSON-format artifact of the forum post
91 the being-edited post, as returned by /ajax/artifact.json.
92
93 opt.status: optional current status tag value for opt.edit,
94 if known. This is used for pre-selecting a status value.
95
96 opt.hideStash[bool=false]: if true, the "Stash" button does not
97 get added. Intended for use with /forumnew.
98 */
99 constructor(opt){
100 opt = this.#opt = F.nu({
101 draftKey: undefined,
102 hideStash: false
103 }, opt);
104 opt.isNewThread = !opt.inReplyTo && !opt.edit;
105 if( opt.draftKey ){
106 this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {}));
107 }
108 const e = this.#e = F.nu({
109 mimetype: F.nu(),
110 button: F.nu()
111 });
112 //console.debug("Setting up FPE opt =",opt);
113 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
114 D.clearElement(wrapper);
115
116 if( !opt.inReplyTo ){
117 /* Title... */
118 e.titleBar = D.addClass(D.div(),'titlebar');
119 e.title = D.attr(
120 D.addClass(D.input('text'), 'title'),
121 'placeholder',
122 'Thread title (required)'
123 );
124 e.title.setAttribute('maxlength', 125);
125 e.titleBar.append(
126 D.append(D.span(), "Title:"),
127 e.title
128 );
129 if( this.#draft ){
130 e.title.addEventListener('blur', ()=>{
131 this.#draft.title = e.title.value;
132 this.#storeDraft();
133 });
134 e.title.value = this.#draft.title || opt.edit?.H || '';
135 }else if( opt.edit?.H ){
136 e.title.value = opt.edit.H;
137 }
138 wrapper.append(e.titleBar);
139 }
140
141 { /* Mimetype... */
142 e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
143 const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
144 sel.setAttribute('title', 'Markup format for this post.');
145 this.#toDisable.push(sel);
146 let i = 0;
147 D.option(sel, '', '- Markup format -').disabled = true;
148 for(const [k,v] of Object.entries({
149 'text/x-markdown': 'Markdown',
150 'text/x-fossil-wiki': 'Fossil Wiki',
151 'text/plain': 'Plain text'
152 })) {
153 D.option(sel, k, v);
154 }
155 sel.value = opt.mimetype
156 || this.#draft?.mimetype
157 || F.storage.get('forum-mimetype', sel.options[1].value);
158 sel.addEventListener('change',ev=>{
159 if( this.#draft && this.#draft.mimetype!==ev.target.value ){
160 this.#draft.mimetype = ev.target.value;
161 this.#storeDraft();
162 }
163 F.storage.set('forum-mimetype', ev.target.value);
164 });
165 e.mimetype.wrapper.append(sel);
166 }
167
168 e.buttons = D.addClass(D.div(), 'buttons');
169 { /* Preview/submit buttons... */
170 e.button.preview = D.attr(
171 D.button("Preview", e=>this.#preview()),
172 'title',
173 'Preview your edits.'
174 );
175 e.button.submit = D.attr(
176 D.button("Submit"),
177 'title',
178 'Save any edits to the server. Not permitted until Preview has been used.'
179 );
180 if( this.#draft && !opt.hideStash ){
181 e.button.stash = D.attr(
182 D.button(
183 "Stash", e=>this.close()
184 /* This could be called Close, but that would semantically
185 collide with the Close [this post] button. All "Stash"
186 does is close the widget. */
187 ),
188 'title', "Close this editor and stash any edits locally."
189 );
190 }
191 if( opt.ondiscard instanceof Function ){
192 e.button.discard = D.attr(
193 D.button('Discard'),
194 'title',
195 'Close the editor and discard all local edits.'
196 );
197 }
198 if( 1 ){
199 F.confirmer(e.button.submit, {
200 confirmText: "Confirm submit...",
201 onconfirm: ()=>this.#submit()
202 });
203 if( e.button.discard ){
204 F.confirmer(e.button.discard, {
205 confirmText: "Really discard?",
206 onconfirm: ()=>this.discard()
207 });
208 }
209 }else{
210 e.button.submit.addEventListener('click', ()=>this.#submit());
211 if( e.button.discard ){
212 e.button.submit.addEventListener('click', ()=>this.discard());
213 }
214 }
215 e.button.submit.setAttribute('disabled', '');
216 wrapper.append(e.buttons);
217
218 e.error = D.addClass(D.div(), 'error', 'hidden');
219 wrapper.append(e.error);
220 e.error.addEventListener('dblclick',()=>this.reportError());
221 }
222
223 const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
224 { /* Main tabs... */
225 e.tabs = D.attr(
226 D.addClass(D.div(), 'tab-container'),
227 'id', idPrefix+'-tabs'
228 );
229 this.#tabs = new F.TabManager(e.tabs);
230 this.#tabs.addEventListener('before-switch-to', (ev)=>{
231 //console.debug("Switching to tab",ev.detail);
232 switch( (this.#activeTab = ev.detail) ){
233 case e.preview:
234 this.#e.button.preview.click();
235 break;
236 case e.help:
237 if( e.help.$needsInit ){
238 delete e.help.$needsInit;
239 this.#initHelpTab();
240 }
241 break;
242 case e.tabAttach:
243 if( !this.#att ) this.#initAttacherTab();
244 break;
245 }
246 });
247 wrapper.append( e.tabs );
248
249 e.tabEdit = D.div();
250 e.tabEdit.classList.add('editor-wrapper');
251 e.editor = D.attr(
252 D.addClass(D.textarea(), 'editor'),
253 'placeholder',
254 'Your message to other forum-goers...'
255 );
256 e.tabEdit.append(e.editor);
257 e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo)
258 ? 'Edit' : 'Reply';
259 this.#tabs.addTab( e.tabEdit );
260 this.#tabs.switchToTab( e.tabEdit );
261 if( this.#draft ){
262 this.editorContent = this.#draft.content || opt.edit?.W || '';
263 e.editor.addEventListener(
264 'blur', ()=>{
265 this.#draft.content = this.editorContent;
266 this.#storeDraft();
267 }
268 );
269 }else if( opt.edit?.W ){
270 this.editorContent = opt.artifact.W;
271 }
272 e.preview = D.addClass(D.div(), 'preview');
273 e.preview.dataset.tabLabel = 'Preview';
274 this.#toDisable.push(e.button.preview);
275 this.#tabs.addTab( e.preview );
276 }
277
278 if( F.user.enableDebug ){
279 e.debug = D.addClass(D.div(), 'debug');
280 e.debug.dataset.tabLabel = 'Debug';
281 e.debug.setAttribute('id', idPrefix+'-debug');
282 for(const [k,v] of Object.entries({
283 dryrun: 'Dry run',
284 domod: 'Require moderation approval',
285 //showqp: 'Show query parameters',
286 fpsilent: 'Do not send notification emails'
287 })){
288 const lbl = D.label(false, v);
289 lbl.prepend(D.checkbox(k));
290 e.debug.append(lbl);
291 }
292 this.#tabs.addTab(e.debug);
293 }
294 e.buttons.append(e.mimetype.wrapper);
295
296 if( opt.edit
297 && !opt.inReplyTo
298 && F.config.forumStatuses?.length>0 ){
299 const sel = e.status = D.select();
300 sel.setAttribute('title', 'The status tag value for this post.');
301 D.option(sel, "", "- Status -").disabled = true;
302 for( const status of F.config.forumStatuses ){
303 D.option(sel, status.value, status.label);
304 }
305 e.buttons.append(sel);
306 if( opt.status ){
307 sel.value = opt.status;
308 }else if( this.#draft ){
309 if( this.#draft.status ){
310 sel.value = this.#draft.status;
311 }else{
312 this.#draft.status = sel.value = F.config.forumStatuses[0].value;
313 }
314 sel.addEventListener('change',ev=>{
315 const v = sel.value;
316 if( this.#draft.status !== v ){
317 this.#draft.status = v;
318 this.#storeDraft();
319 }
320 });
321 }
322 }/*e.status*/
323
324 if( F.user.mayAttachForum ){
325 //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
326 e.tabAttach = D.div();
327 e.tabAttach.setAttribute('id', idPrefix+'-attach');
328 e.tabAttach.dataset.tabLabel = 'Attachments';
329 this.#tabs.addTab(e.tabAttach);
330 /* Reminder: we don't currently have a way to disable/enable
331 an Attacher's controls during ajax traffic. */
332 }
333 e.buttons.append(e.button.preview, e.button.submit);
334 if( e.button.stash ){
335 e.buttons.append(e.button.stash);
336 this.#toDisable.push(e.button.stash);
337 }
338 if( e.button.discard ){
339 e.buttons.append(e.button.discard);
340 this.#toDisable.push(e.button.discard);
341 }
342
343 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
344 e.help.$needsInit = true;
345 e.help.dataset.tabLabel = 'Help';
346 this.#tabs.addTab(e.help);
347
348 if( opt.hiddenFields ){
349 this.addHiddenFields( opt.hiddenFields );
350 delete opt.hiddenFields;
351 }
352
353 { /* Shift-enter pieces... */
354 const eCb = D.checkbox(1);
355 const eLbl = D.label();
356 const eHelp = D.append(
357 D.span(), [
358 'When checked, shift-enter will toggle between preview ',
359 'and edit modes, which is generally useful but some ',
360 'software keyboards misinteract with it. If the preview ',
361 'starts when tapping Enter, turn this setting off.'
362 ].join('')
363 );
364 eCb.checked = F.storage.getBool(
365 'edit-shift-enter-preview',
366 true
367 /* Maintenance reminder: this setting is shared across
368 several apps, like /chat, /wikiedit, and /fileedit. */
369 );
370 eCb.addEventListener('change', (ev)=>{
371 F.storage.set('edit-shift-enter-preview', eCb.checked);
372 });
373 F.helpButtonlets.setup(eHelp);
374 eLbl.append("Shift-enter toggles preview?", eCb, eHelp);
375 e.tabEdit.append(eLbl);
376 const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode;
377 e.editor.addEventListener('keydown',(ev)=>{
378 /**
379 If eCb.checked is true, a keyboard combo of shift-enter
380 (from the editor) toggles between preview and edit modes.
381 This is normally desired but at least one software
382 keyboard is known to misinteract with this, treating an
383 Enter after automatically-capitalized letters as a
384 shift-enter:
385
386 https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8
387 */
388 if(!isShiftEnter(ev)) return;
389 ev.preventDefault();
390 ev.stopPropagation();
391 e.editor.blur(/*force draft update if needed*/);
392 this.#tabs.switchToTab(e.preview);
393 }, false);
394 // If we're in the preview tab, have ctrl-enter switch back to the editor.
395 document.body.addEventListener('keydown',(ev)=>{
396 if(!isShiftEnter(ev)) return;
397 if(this.#activeTab !== e.tabEdit){
398 ev.preventDefault();
399 ev.stopPropagation();
400 this.#tabs.switchToTab(e.tabEdit);
401 e.editor.focus(/*slow as molasses for long docs, as focus()
402 forces a document reflow. */);
403 return false;
404 }
405 }, true);
406 }/*shift-enter preview bits*/
407
408 if(0){ /* Needs to be optional */
409 const elemsToToggle = document.body.querySelectorAll(
410 ':scope > header, :scope > nav'
411 );
412 e.button.toggleHeader =
413 D.button('Toggle header', e=>{
414 for(const et of elemsToToggle){
415 et.classList.toggle('hidden');
416 }
417 });
418 e.buttons.append(e.button.toggleHeader);
419 }
420
421 {
422 const eLbl = D.label(false, "Posting as "+F.user.name)
423 eLbl.classList.add('logged-in-as');
424 e.buttons.append(eLbl);
425 }
426
427 }/*constructor*/
428
429 /*
430 ** Removes this object from the DOM. It has no side effects if
431 ** it's not in the DOM.
432 */
433 close(){
434 const e = this.#e.widget;
435 if( e?.parentNode ){
436 if( this.#opt.onclose instanceof Function ){
437 try{this.#opt.onclose();}
438 catch(e){
439 console.error("ForumPostEditor.onclose() threw:",e);
440 }
441 }
442 //console.debug("FPE discarding", this);
443 e.classList.add('animate-exit');
444 e.addEventListener('animationend', ()=>e.remove(), {once: true});
445 dummyPadding.refs.delete(this);
446 if( 0===dummyPadding.refs.size ){
447 dummyPadding.remove();
448 }
449 }
450 }
451
452 /*
453 ** Discards any draft edits then calls close(). If an ondiscard
454 ** callback was provided to the constructor then it is called
455 ** before the drafts are cleared and any exceptions it throws are
456 ** ignored (but may be logged).
457 */
458 discard(){
459 if( this.#opt.ondiscard instanceof Function ){
460 try{this.#opt.ondiscard(this);}
461 catch(e){
462 console.error("ForumPostEditor.ondiscard() threw:",e);
463 }
464 }
465 this.#clearDraft();
466 this.close();
467 }
468
469 /** This widget's top-most DOM element. */
470 get widget(){
471 if( !dummyPadding.parentElement ){
472 document.body.append(dummyPadding);
473 }
474 dummyPadding.refs.add(this);
475 return this.#e.widget;
476 }
477
478 get editorContent(){
479 /* We wrap access to the editor's contents in a getter/setter so
480 that we can eventually add optional use of a contenteditable
481 edit field, as those are generally more comfortable. The code
482 for that is in fossil.page.chat.js. */
483 return this.#e.editor.value;
484 }
485
486 set editorContent(v){
487 this.#e.editor.value = v;
488 }
489
490 /**
491 Reports an error by appending each argument to the error widget
492 and unhiding it. If passed no arugments, it clears and hides
493 the error widget.
494 */
495 reportError(...msg){
496 const e = this.#e.error;
497 D.clearElement(e);
498 if( msg.length ){
499 console.error('ForumPostEditor:',...msg);
500 e.classList.remove('hidden');
501 e.append(
502 ...msg, D.br(),
503 D.button("Clear", ()=>this.reportError())
504 /* Looks horrid in the Blitz skin */
505 );
506 }else{
507 e.classList.add('hidden');
508 }
509 }
510
511 /**
512 Adds a list of input[type=hidden] form fields to this object,
513 imported from the server-generated HTML. This is used for
514 collecting, e.g., the CSRF token and an initial page title.
515 */
516 addHiddenFields(list){
517 this.#extraFields ??= [];
518 for( const f of list ){
519 if( !f ) continue;
520 if( 'title'===f.name && this.#e.title ){
521 if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
522 this.#e.title.value = f.value;
523 }
524 }else{
525 this.#extraFields.push(f);
526 }
527 }
528 }
529
530 get mimetype(){
531 return this.#e.mimetype.select.value;
532 }
533
534 get title(){
535 return this.#e.title?.value || this.#opt.edit?.H;
536 }
537
538 #initHelpTab(){
539 const eh = this.#e.help;
540 const list = D.ul();
541 D.append(
542 D.li(list),
543 D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
544 'target', '_new')
545 );
546 D.append(
547 D.li(list),
548 "WARNING: draft edits are keyed on the ID of the message they ",
549 "are editing or responding to. Attempting to edit or reply to ",
550 "the same post from multiple tabs will cause the most-recently-edited ",
551 "one to overwrite the draft slot for that post. In browsers which support ",
552 "Web Locks, a second attempt to edit or reply to a post will be blocked ",
553 "and an error will be shown explaining the problem."
554 );
555 if( this.#e.status ){
556 D.append(
557 D.li(list),
558 "Tip: changing just the status in the editor will change only that, ",
559 "not a whole new (but unedited) copy of the post."
560 );
561 }
562 eh.append(list);
563 }
564
565 #initAttacherTab(){
566 this.#att = new F.Attacher({
567 reverse: true
568 });
569 if( this.#opt.edit ){
570 const eNote = D.append(
571 D.div(),
572 "Tip: attachments can be added to posts without editing them ",
573 "by visiting ",
574 D.attr(
575 D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'),
576 'target',
577 '_new'
578 ),
579 ".",
580 );
581 this.#e.tabAttach.append(eNote);
582 }
583 this.#e.tabAttach.append(this.#att.widget);
584 }
585
586 #newFormData(addThisContent){
587 const fd = new FormData;
588 for(const f of this.#extraFields){
589 fd.append(f.name, f.value);
590 }
591 let v;
592 if( this.#opt.inReplyTo ){
593 fd.append( 'firt', this.#opt.inReplyTo );
594 }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){
595 fd.append('title', v);
596 }
597 fd.append('mimetype', this.mimetype);
598 fd.append('content', addThisContent || this.editorContent.trim());
599 return fd;
600 }
601
602 async #fetchPreview(content){
603 /* TODO: fetch preview */
604 const e = this.#e;
605 const fd = /*no: this.#newFormData(content); */
606 new FormData;
607 let ext;
608 switch(this.mimetype){
609 case 'text/x-markdown': ext = 'md'; break;
610 case 'text/x-fossil-wiki': ext = 'wiki'; break;
611 default: ext = 'txt'; break;
612 }
613 fd.append('filename', 'x.'+ext/*for mimetype determination*/);
614 fd.append('content', this.editorContent.trim());
615 return window
616 .fetch(F.repoUrl('ajax/preview-text'),{
617 method: 'POST',
618 body: fd
619 })
620 .then(r=>r.text())
621 .then(t=>{
622 if( /^\{.*}$/.test(t) ){
623 const o = JSON.parse(t);
624 throw new Error(o.error);
625 }
626 return t;
627 });
628 }
629
630 #setPreviewContent(rawHtml){
631 /**
632 Append the new content then remove the old, to help reduce
633 jumping-around of the UI if the preview is cleared then
634 repopulated.
635 */
636 const preview = this.#e.preview;
637 const childs = [...preview.childNodes];
638 D.parseHtml(preview, rawHtml);
639 D.remove(childs);
640 //preview.style.removeProperty('height');
641 if(F.pikchr && 'text/x-markdown'===this.mimetype){
642 F.pikchr.addSrcView(
643 preview.querySelectorAll('svg.pikchr')
644 );
645 }
646 }
647
648 async #preview(){
649 if( this.#isWaiting ) return;
650 const e = this.#e;
651 if( e.preview !== this.#activeTab ){
652 this.#tabs.switchToTab(e.preview);
653 /* Will recurse into here */
654 return;
655 }
656 const content = this.editorContent.trim();
657 //console.debug("content to preview", content);
658 if( !content ){
659 return;
660 }
661 if( 0
662 && !e.preview.firstElementChild ){
663 /* On an initial first preview, inherit the editor's height to
664 reduce jumping-around of the UI. */
665 if( 0 /* does not work: height of the editor is "auto" */ ){
666 const c = window.getComputedStyle(e.editor/*tabEdit*/);
667 e.preview.style.height = c.height;
668 }else{
669 e.preview.style.height = '20em';
670 }
671 }
672 this.#isWaiting = true;
673 D.disable(this.#toDisable, e.button.submit);
674 this.#fetchPreview(content)
675 .then((c)=>{
676 this.#setPreviewContent(c);
677 D.enable(e.button.submit);
678 })
679 .catch(err=>{
680 e.preview.textContent = "Error fetching preview: "+err.message;
681 console.error("Error fetching preview:",err);
682 this.reportError(err.message);
683 })
684 .finally(()=>{
685 this.#isWaiting = false;
686 D.enable(this.#toDisable);
687 });
688 }
689
690 #validate(tgt){
691 if( this.#e.title ){
692 const v = this.#e.title.value.trim();
693 if( !v ){
694 this.reportError("A non-empty title is required.");
695 return;
696 }
697 }
698 return true;
699 }
700
701 #submit(){
702 if( this.#isWaiting ) return;
703 if( !this.#validate() ) return;
704 this.#isWaiting = true;
705 const e = this.#e;
706 D.disable(e.button.submit);
707 const fd = this.#newFormData();
708 if( this.#e.status ){
709 /* Send the status only if it was modified, otherwise we may
710 add a superfluous tag. */
711 const v = this.#e.status.value;
712 if( this.#e.status.dataset.originalValue !== v ){
713 fd.append("status", v);
714 }
715 }
716 if( e.debug ){
717 e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{
718 if( cb.checked ){
719 fd.append(cb.value, 1);
720 //console.debug("Forum post debug option:",cb);
721 }
722 });
723 }
724 if( this.#att ){
725 this.#att.populateFormData(fd);
726 }
727 //console.warn("Ready to submit",fd);
728 if( 0 ){
729 this.#isWaiting = false;
730 return;
731 }
732 const resp = window.fetch(F.repoUrl('forumajax_save'), {
733 method: 'POST',
734 body: fd
735 }).then(r=>r.json())
736 .then(j=>{
737 j = F.nu(j);
738 console.debug("forum post editor response:",j);
739 if( j.error ){
740 throw new Error(j.error);
741 }else if( j.message ){
742 /* This is only for use in debugging during
743 * development. */
744 this.reportError(j.message);
745 return;
746 }
747 if( 1 ){
748 this.#clearDraft();
749 if( this.#opt.onsubmit instanceof Function ){
750 try{this.#opt.onsubmit(this, j);}
751 catch(e){
752 console.error("ForumPostEditor.onsubmit() threw: ", e);
753 }
754 }
755 /*
756 if( this.#opt.edit?.uuid === j.uuid ) then we know the
757 content did not change, but it's possible that attachments
758 and/or a status tag did. Ergo, we need to unconditionally
759 reload to render those changes (if any). The other option
760 is to tell the user "nothing changed" and leave them in
761 the editor, but that could be a lie because we don't know
762 if any attachments or tags were changed.
763 */
764 else if( 0 ){
765 if( this.#opt.edit.uuid === j.uuid
766 && !j.statusModified && 0===j.attachedCount ){
767 this.reportError("No changes made.");
768 }else{
769 window.location = F.repoUrl('forumpost/'+j.uuid);
770 setTimeout(()=>this.close(), 500/*just in case not redirected*/);
771 }
772 }
773 }else{
774 this.reportError(
775 "Saving worked but we're ignoring it and staying here."
776 );
777 }
778 })
779 .catch((e)=>this.reportError(e.message))
780 .finally(()=>this.#isWaiting = false);
781 }
782
783 #storeDraft(){
784 if( this.#draft ){
785 this.#draft.mtime = Date.now();
786 F.storage.setJSON(this.#opt.draftKey, this.#draft);
787 }
788 }
789
790 /** Clears any persistent draft state. Does not clear the UI
791 widgets. */
792 #clearDraft(){
793 if( this.#draft ){
794 F.storage.remove(this.#opt.draftKey);
795 this.#draft = F.nu();
796 }
797 }
798
799 /**
800 Looks for editing draft keys matching either a fixed key or a
801 regex, and removes each matching one which is older than the
802 given number of days. Pass days=0 to purge all entries
803 immediately.
804 */
805 static purgeOldDrafts(key, days=10){
806 const age = (3600 * 24 * days) * 1000/*ms*/;
807 const now = Date.now();
808 const check = (k)=>{
809 const o = F.storage.getJSON(k);
810 if( o && o.mtime && (!days || (o.mtime+age <= now)) ){
811 F.storage.remove(k);
812 }
813 };
814 if( key instanceof RegExp ){
815 for(const k of F.storage.keys(false).filter(v=>key.test(v))){
816 check(k);
817 }
818 }else{
819 check(key);
820 }
821 }
822
823 async #fetchPost(){
824 /*
825 TODO: when editing an existing post, fetch the raw body of the
826 post and populate this.e.
827 */
828 }
829 }/*ForumPostEditor*/;
830 F.ForumPostEditor = ForumPostEditor;
831
832 /**
833 When the page is loaded, this handler does the following:
834
835 1. Installs expand/collapse UI elements on "long" posts and collapses
836 them.
837
838 2. Any pikchr-generated SVGs get a source-toggle button added to them
839 which activates when the mouse is over the image or it is tapped.
840
841 3. Plugs in a new edit/reply widget to forum posts.
842
843 This is a harmless no-op if the current page has neither forum
844 post constructs for (1) and (3) nor any pikchr images for (2),
845 nor will NOT running this code cause any breakage for clients
846 with no JS support: this is all "nice-to-have", not required
847 functionality.
848 */
849 F.onPageLoad(function(){
850 const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
851 /* Returns an event handler which implements the post expand/collapse toggle
852 on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
933 const eStatus = document.querySelector(
934 'form div.submenu select.submenuctrl[name="status"]'
935 );
936 if( eStatus ){
937 /* Main /forum list. Remove the 'x' form element when eStatus
938 changes, to avoid propagating x when changing the filter.
939 The problem this solves: we're browsed to page 3 of status X.
940 We change the status filter selection to Y. We're redirected
941 to page x, but Y only has 2 posts with that status, so we see
942 an empty list. When changing the filter, we need to ensure
943 that we start back and that beginning. */
944 const pForm = eStatus.parentElement?.parentElement;
945 if( pForm ){
946 eStatus.addEventListener('change', ()=>{
947 pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.();
948 }, true);
949 }
950 }else{
951 /* One of the single-post edit/view pages. Handle various UI
952 controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
959 return;
960 }
961 form.dataset.submitted = '1';
962 /** If the user is left waiting "a long time," disable the
963 resubmit protection. If we don't do this and they tap the
964 browser's cancel button while waiting, they'll be stuck
965 with an unsubmittable form. It can apparently also happen,
966 via browser-back, that the form gets left in a submitted
967 state. */
968 setTimeout(()=>{delete form.dataset.submitted}, 7000);
969 return;
970 };
971
972 document.querySelectorAll("form").forEach(function(form){
973 /* Set up controls for closing posts and setting thread
974 status. */
975 form.addEventListener('submit', formSubmitted);
976 form
977 .querySelectorAll("input.action-close, input.action-reopen")
978 .forEach(function(e){
979 e.classList.remove('hidden');
@@ -149,11 +987,13 @@
987 form
988 .querySelectorAll("input[type='button'].action-status")
989 .forEach(function(btn){
990 btn.classList.remove('hidden');
991 const sel = btn.previousElementSibling;
992 const updateButton = ()=>{
993 /* Enable btn only when the status has been locally
994 modified. */
995 if( sel.dataset.initialValue ){
996 if( sel.dataset.initialValue===sel.value ){
997 btn.setAttribute('disabled','');
998 }else{
999 btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
1004 }else{
1005 btn.removeAttribute('disabled');
1006 }
1007 }
1008 };
1009 sel.addEventListener('change', updateButton, true);
1010 updateButton();
1011 F.confirmer(btn, {
1012 confirmText: "Confirm status change",
1013 onconfirm: ()=>form.submit()
1014 });
1015 });
1016 });
1017 }
1018
1019 /* Apply page-specific tweaks for ForumPostEditor instance fpe
1020 then plug it into the UI at the end of ePost. */
1021 const initFPEWidget = (fpe, ePost)=>{
1022 ePost.eUnhideThenWhenDone = [
1023 /* List of elements to hide while editing/replying and reveal
1024 when discarding or saving. */
1025 ];
1026 for( const ee of ePost.querySelectorAll(
1027 '.forumpost-single-controls, fieldset.forum-status-selection'
1028 ) ){
1029 ee.hidden = true;
1030 ePost.eUnhideThenWhenDone.push(ee);
1031 }
1032 const w = fpe.widget;
1033 w.classList.add('animate-entrance');
1034 ePost.append(w);
1035 requestAnimationFrame(() => {
1036 w.scrollIntoView({
1037 behavior: 'smooth',
1038 block: 'nearest',
1039 inline: 'nearest'
1040 });
1041 });
1042 };
1043
1044 const plugInEditor =
1045 (new URL(window.location).searchParams).get('nojs')===null;
1046
1047 const eForumNew = (
1048 plugInEditor
1049 && (
1050 document.body.classList.contains('cpage-forumnew')
1051 || document.body.classList.contains('cpage-forume1')
1052 ))
1053 ? document.body.querySelector('#forumnew-placeholder')
1054 : null;
1055 if( plugInEditor && eForumNew ){
1056 /* /forumnew and /forume1 */
1057 const fpe = new F.ForumPostEditor({
1058 draftKey: 'draft-forumnew',
1059 hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
1060 hideStash: true,
1061 ondiscard: ()=>{
1062 window.location = F.repoUrl('forum');
1063 },
1064 onsubmit: (fpe, response)=>{
1065 window.location = F.repoUrl('forumpost/'+response.uuid);
1066 }
1067 });
1068 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
1069 eForumNew.remove();
1070 fossil.page.fpe = fpe /* for testing via the console */;
1071 }/*eForumNew*/
1072 else if( plugInEditor
1073 && (document.body.classList.contains('cpage-forumpost')
1074 || document.body.classList.contains('cpage-forumthread')) ){
1075 /* /forumpost and /forumthread. Take over the Edit/Reply buttons
1076 to use a ForumPostEditor. */
1077
1078 const fetchPost = async (fpid)=>{
1079 return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
1080 .then(r=>r.json())
1081 .then(j=>{
1082 j = F.nu(j);
1083 if( j.error ) throw new Error(j.error);
1084 return j;
1085 });
1086 };
1087
1088 const makeDraftKey = (prefix,uuid)=>{
1089 return prefix+'-'+uuid.substr(0,12);
1090 };
1091
1092 /**
1093 Perform some init common to both Reply and Edit. ePost = the
1094 forum post DOM element. eButton = the Reply or Edit
1095 button.
1096 */
1097 const setupEditReplyElement = (ePost, eButton)=>{
1098 /* Forum posts are indented in the main forum view to
1099 represent their place in the hierarchy. In order to gain
1100 some screen space, we shift the post to the left margin and
1101 arrange to shift it back when the editor is closed. We also
1102 record the original button label so that it can be
1103 restored on close. */
1104 ePost.dataset.originalMarginLeft = ePost.style.marginLeft;
1105 ePost.style.marginLeft = 'initial';
1106 eButton.dataset.originalLabel = eButton.innerText;
1107 };
1108
1109 /** Undoes the damage done by setupEditReplyElement(). */
1110 const restoreEditReplyElement = (ePost, eButton)=>{
1111 if( ePost.dataset.originalMarginLeft ){
1112 ePost.style.marginLeft = ePost.dataset.originalMarginLeft;
1113 delete ePost.dataset.originalMarginLeft;
1114 }
1115 if( eButton.dataset.originalLabel ){
1116 eButton.innerText = eButton.dataset.originalLabel;
1117 delete eButton.dataset.originalLabel;
1118 }
1119 for(const ee of (ePost.eUnhideThenWhenDone || [])){
1120 ee.removeAttribute('hidden');
1121 }
1122 ePost.eUnhideThenWhenDone = undefined;
1123 };
1124
1125 /**
1126 Reports an error regarding the forum post element
1127 ePost, appending each entry in msg to a wrapper
1128 element with the class
1129 */
1130 const reportFPEError = (ePost,...msg)=>{
1131 const e = D.addClass(D.p(), 'error');
1132 e.append(
1133 ...msg,
1134 D.br(),
1135 D.button("Clear error", ()=>e.remove())
1136 );
1137 ePost.append(e);
1138 };
1139
1140 /**
1141 Plug in an editor widget representing a reply to a post.
1142 form = a (.forum-post-single-controls > form) element. The
1143 final 3 arguments are as documented for
1144 setupEditReplyElement().
1145 */
1146 const replyClicked = async (form, ePost, eBtnReply)=>{
1147 const fpid = ePost.dataset.fpid;
1148 const fEditHead = ePost.dataset.fedithead;
1149 const draftKey = makeDraftKey(
1150 'draft-reply', fEditHead
1151 /* The problem with firt as a key is that firt is not
1152 necessarily the root edit of that post, which is what we
1153 really want as a draft key so that the draft does not
1154 disappear if firt is later edited (giving us a new firt
1155 value here). */
1156 || fpid
1157 );
1158 let releaseLock;
1159 if( window.navigator.locks ){
1160 releaseLock = await new Promise((resolve)=>{
1161 window.navigator.locks.request(
1162 'fossil-'+draftKey,
1163 {ifAvailable: true},
1164 async (lock) => {
1165 if( !lock ){
1166 /*lock contention*/
1167 resolve(null);
1168 return;
1169 }
1170 let release;
1171 const lockReleased = new Promise(res=>release=res);
1172 resolve(release);
1173 await lockReleased/*hold the lock open*/;
1174 });
1175 });
1176 if( !releaseLock ){
1177 reportFPEError(
1178 ePost,
1179 "This post is actively being replied to ",
1180 "in another tab. To avoid losing edits, ",
1181 "it cannot be opened here until the locking ",
1182 "tab is closed."
1183 );
1184 return;
1185 }
1186 }
1187
1188 setupEditReplyElement(ePost, eBtnReply);
1189 eBtnReply.innerText = "Replying...";
1190 const ondone = (fpe, response)=>{
1191 /* onsubmit() and ondiscard() callback */
1192 restoreEditReplyElement(ePost, eBtnReply);
1193 //console.debug("ondiscard/onsubmit", fpe, artifact);
1194 if( response/*onsubmit()*/ ){
1195 window.location = F.repoUrl('forumpost/'+response.uuid);
1196 setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1197 }else{/*ondiscard() or onclose()*/
1198 }
1199 };
1200 const fpe = new F.ForumPostEditor(F.nu({
1201 hiddenFields: form.querySelectorAll(
1202 'input[type=hidden][name=csrf]'
1203 /* Do not inherit the fpid field, else this will become
1204 an edit to that post rather than a response. */
1205 ),
1206 ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1207 onsubmit: ondone,
1208 onclose: ()=>{
1209 if( releaseLock ){
1210 releaseLock();
1211 releaseLock = null;
1212 }
1213 ondone();
1214 },
1215 inReplyTo: fpid,
1216 draftKey
1217 }));
1218 initFPEWidget(fpe, ePost);
1219 }/*replyClicked()*/;
1220
1221 /**
1222 Plug in an editor widget representing an edit to a post.
1223 form = a (.forum-post-single-controls > form) element. The
1224 final 3 arguments are as documented for
1225 setupEditReplyElement().
1226 */
1227 const editClicked = async (form, ePost, eBtnEdit)=>{
1228 const fpid = ePost.dataset.fpid;
1229 const firt = ePost.dataset.firt;
1230 const fEditHead = ePost.dataset.fedithead;
1231 const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1232 let releaseLock;
1233 if( navigator.locks ){
1234 releaseLock = await new Promise((resolve) => {
1235 navigator.locks.request(
1236 'fossil-'+draftKey,
1237 {ifAvailable: true},
1238 async (lock)=>{
1239 if( !lock ){
1240 resolve(null);
1241 return;
1242 }
1243 let release;
1244 const lockReleased = new Promise(res=>release=res);
1245 resolve(release);
1246 await lockReleased;
1247 });
1248 });
1249
1250 if( !releaseLock ){
1251 reportFPEError(
1252 ePost,
1253 "This post is actively being edited ",
1254 "in another tab. To avoid losing edits, ",
1255 "it cannot be opened here until the locking ",
1256 "tab is closed."
1257 );
1258 return;
1259 }
1260 }
1261 setupEditReplyElement(ePost, eBtnEdit);
1262 eBtnEdit.innerText = "Editing...";
1263 fetchPost(fpid)
1264 .then(artifact=>{
1265 const ondone = (fpe, response)=>{
1266 /* onsubmit() and ondiscard() callback */
1267 if( response/*onsubmit()*/ ){
1268 if( fpid === response.uuid
1269 && !response.statusModified
1270 && 0===response.attachedCount ){
1271 fpe.reportError("No changes made.");
1272 }else{
1273 restoreEditReplyElement(ePost, eBtnEdit);
1274 window.location = F.repoUrl('forumpost/'+response.uuid);
1275 setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1276 }
1277 }else{
1278 /*ondiscard() or onclose()*/
1279 restoreEditReplyElement(ePost, eBtnEdit);
1280 }
1281 };
1282 const eStatusSelect = ePost.querySelector(
1283 ':scope > fieldset.forum-status-selection select[name=status]'
1284 );
1285
1286 const fpe = new F.ForumPostEditor(F.nu({
1287 hiddenFields: form.querySelectorAll('input[type=hidden]'),
1288 ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1289 onsubmit: ondone,
1290 onclose: ()=>{
1291 if(releaseLock){
1292 releaseLock();
1293 releaseLock = null;
1294 }
1295 ondone();
1296 },
1297 draftKey,
1298 edit: artifact,
1299 status: eStatusSelect?.value,
1300 inReplyTo: firt
1301 }));
1302 initFPEWidget(fpe, ePost);
1303 })
1304 .catch(err=>{
1305 if( releaseLock ){
1306 releaseLock();
1307 releaseLock = null;
1308 }
1309 restoreEditReplyElement(ePost, eBtnEdit);
1310 console.error("Error fetching post:", err);
1311 reportFPEError(ePost, "Error fetching post: ", err.message);
1312 });
1313 }/*editClicked()*/;
1314
1315 document.body.querySelectorAll(
1316 '.forumpost-single-controls > form'
1317 ).forEach(form=>{
1318 /* For each forum post... */
1319 const eThePost = form.parentElement.parentElement/*main post DOM element*/;
1320 if( !eThePost?.dataset?.fpid ){
1321 /* The server injects these dataset values. */
1322 console.warn("Unexpected missing fpid", eThePost);
1323 return;
1324 }
1325 const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{
1326 /* If a draft is found associated with eThePost, mark eBtn
1327 as a draft and set up storage event listeners to update
1328 the button as new drafts come and go. */
1329 const fpid = eThePost.dataset.fpid;
1330 const fEditHead = eThePost.dataset.fedithead;
1331 const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid);
1332 if( F.storage.contains(draftKey) ){
1333 eBtn.classList.add('draft');
1334 }
1335 F.storage.addEventListener('set', ({detail})=>{
1336 if( draftKey === detail.key ){
1337 eBtn.classList.add('draft');
1338 }
1339 });
1340 F.storage.addEventListener('remove', ({detail})=>{
1341 if( draftKey === detail.key ){
1342 eBtn.classList.remove('draft');
1343 }
1344 });
1345 };
1346 /* Replace the Reply and Edit buttons with ones which will activate
1347 a ForumPostEditor. */
1348 const btnReply = form.querySelector('input[type=submit][name=reply]');
1349 if( btnReply ){
1350 const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
1351 b.type = 'button'/*keep container form from submitting*/;
1352 checkButtonForDraft('draft-reply',b);
1353 btnReply.parentElement.insertBefore(b, btnReply);
1354 btnReply.remove();
1355 }
1356 const btnEdit = form.querySelector('input[type=submit][name=edit]');
1357 if( btnEdit ){
1358 const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
1359 b.type = 'button'/*keep container form from submitting*/;
1360 checkButtonForDraft('draft-forumedit',b);
1361 btnEdit.parentElement.insertBefore(b, btnEdit);
1362 btnEdit.remove();
1363 }
1364 })/*for-each form*/;
1365
1366 }/* /forumpost and /forumthread */
1367
1368 document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove());
1369 document.body.querySelectorAll('.initially-hidden').forEach(e=>{
1370 /* This is a workaround for a span.help-buttonlet which we need
1371 to start hidden so that it does not show up for no-JS
1372 clients. */
1373 e.classList.remove('initially-hidden');
1374 });
1375
1376 if( plugInEditor ){
1377 document.body.querySelectorAll('.remove-if-replaced').forEach(
1378 /* Remove remaining legacy UI elements. */ e=>e.remove()
1379 );
1380 /* Purge old drafts only every now and then. */
1381 const now = Date.now();
1382 const lastPurge = +F.storage.get('forum-drafts-last-purge', 0);
1383 if( now - lastPurge > (24 * 60 * 60 * 1000 /*1 day ms*/) ){
1384 F.storage.set('forum-drafts-last-purge', now);
1385 setTimeout(()=>{
1386 /* Don't block the UI while we're doing I/O */
1387 F.ForumPostEditor.purgeOldDrafts(
1388 /^draft-(reply|forumedit)-.*/
1389 /* Intentionally leaving draft-forumnew in place. */
1390 );
1391 }, 50);
1392 }
1393 }
1394 })/*F.onPageLoad callback*/;
1395 })(window.fossil);
1396
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
11871187
if(!wi.attachments || !wi.attachments.length){
11881188
D.append(f.eAttach,
11891189
btnReload,
11901190
" No attachments found for page ["+wi.name+"]. ",
11911191
D.a(F.repoUrl('attachadd',{
1192
- page: wi.name,
1192
+ target: wi.name,
11931193
from: F.repoUrl('wikiedit',{name: wi.name})}),
11941194
"Add attachments..." )
11951195
);
11961196
return this;
11971197
}
@@ -1198,14 +1198,14 @@
11981198
D.append(
11991199
f.eAttach,
12001200
D.append(D.p(),
12011201
btnReload," ",
12021202
D.a(F.repoUrl('attachlist',{page:wi.name}),
1203
- "Attachments for page ["+wi.name+"]."),
1204
- " ",
1203
+ "Attachments for page ["+wi.name+"]"),
1204
+ ". ",
12051205
D.a(F.repoUrl('attachadd',{
1206
- page:wi.name,
1206
+ target:wi.name,
12071207
from: F.repoUrl('wikiedit',{name: wi.name})}),
12081208
"Add attachments..." )
12091209
)
12101210
);
12111211
wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
13881388
setting.
13891389
*/
13901390
P.baseHrefRestore = function(){
13911391
this.base.tag.href = this.base.originalHref;
13921392
};
1393
-
13941393
13951394
/**
13961395
loadPage() loads the given wiki page and updates the relevant
13971396
UI elements to reflect the loaded state. If passed no arguments
13981397
then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
14541453
onload(r);
14551454
}
14561455
});
14571456
return this;
14581457
};
1459
-
1458
+
14601459
/**
14611460
Fetches the page preview based on the contents and settings of
14621461
this page's input fields, and updates the UI with the
14631462
preview.
14641463
14651464
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
1187 if(!wi.attachments || !wi.attachments.length){
1188 D.append(f.eAttach,
1189 btnReload,
1190 " No attachments found for page ["+wi.name+"]. ",
1191 D.a(F.repoUrl('attachadd',{
1192 page: wi.name,
1193 from: F.repoUrl('wikiedit',{name: wi.name})}),
1194 "Add attachments..." )
1195 );
1196 return this;
1197 }
@@ -1198,14 +1198,14 @@
1198 D.append(
1199 f.eAttach,
1200 D.append(D.p(),
1201 btnReload," ",
1202 D.a(F.repoUrl('attachlist',{page:wi.name}),
1203 "Attachments for page ["+wi.name+"]."),
1204 " ",
1205 D.a(F.repoUrl('attachadd',{
1206 page:wi.name,
1207 from: F.repoUrl('wikiedit',{name: wi.name})}),
1208 "Add attachments..." )
1209 )
1210 );
1211 wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
1388 setting.
1389 */
1390 P.baseHrefRestore = function(){
1391 this.base.tag.href = this.base.originalHref;
1392 };
1393
1394
1395 /**
1396 loadPage() loads the given wiki page and updates the relevant
1397 UI elements to reflect the loaded state. If passed no arguments
1398 then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
1454 onload(r);
1455 }
1456 });
1457 return this;
1458 };
1459
1460 /**
1461 Fetches the page preview based on the contents and settings of
1462 this page's input fields, and updates the UI with the
1463 preview.
1464
1465
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
1187 if(!wi.attachments || !wi.attachments.length){
1188 D.append(f.eAttach,
1189 btnReload,
1190 " No attachments found for page ["+wi.name+"]. ",
1191 D.a(F.repoUrl('attachadd',{
1192 target: wi.name,
1193 from: F.repoUrl('wikiedit',{name: wi.name})}),
1194 "Add attachments..." )
1195 );
1196 return this;
1197 }
@@ -1198,14 +1198,14 @@
1198 D.append(
1199 f.eAttach,
1200 D.append(D.p(),
1201 btnReload," ",
1202 D.a(F.repoUrl('attachlist',{page:wi.name}),
1203 "Attachments for page ["+wi.name+"]"),
1204 ". ",
1205 D.a(F.repoUrl('attachadd',{
1206 target:wi.name,
1207 from: F.repoUrl('wikiedit',{name: wi.name})}),
1208 "Add attachments..." )
1209 )
1210 );
1211 wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
1388 setting.
1389 */
1390 P.baseHrefRestore = function(){
1391 this.base.tag.href = this.base.originalHref;
1392 };
 
1393
1394 /**
1395 loadPage() loads the given wiki page and updates the relevant
1396 UI elements to reflect the loaded state. If passed no arguments
1397 then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
1453 onload(r);
1454 }
1455 });
1456 return this;
1457 };
1458
1459 /**
1460 Fetches the page preview based on the contents and settings of
1461 this page's input fields, and updates the UI with the
1462 preview.
1463
1464
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
5555
from the default), the class "fossil-PopupWidget" is always set
5656
in order to allow certain app-internal CSS to account for popup
5757
windows in special cases.
5858
5959
.style: optional object of properties to copy directly into
60
- the element's style object.
60
+ the element's style object.
6161
6262
The options passed to this constructor get normalized into a
6363
separate object which includes any default values for options not
6464
provided by the caller. That object is available this the
6565
resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161161
Sidebar: showing/hiding the widget is, as is conventional for
162162
this framework, done by removing/adding the 'hidden' CSS class
163163
to it, so that class must be defined appropriately.
164164
*/
165165
show: function(){
166
- var x = undefined, y = undefined, showIt,
166
+ let x = undefined, y = undefined, showIt,
167167
wasShown = !this.e.classList.contains('hidden');
168168
if(2===arguments.length){
169169
x = arguments[0];
170170
y = arguments[1];
171171
showIt = true;
@@ -344,11 +344,12 @@
344344
- No arguments, which is equivalent to passing the string
345345
".help-buttonlet:not(.processed)".
346346
347347
Passing the same element(s) more than once is a no-op: during
348348
initialization, each elements get the class'processed' added to
349
- it, and any elements with that class are skipped.
349
+ it, and any elements with that class are skipped. Each element
350
+ gets the 'help-buttonlet' CSS class added to it.
350351
351352
All child nodes of a help buttonlet are removed from the button
352353
during initialization and stashed away for use in a PopupWidget
353354
when the botton is clicked.
354355
@@ -374,24 +375,24 @@
374375
calculate the resulting size, then move and/or resize it.
375376
376377
This algorithm/these heuristics can certainly be improved
377378
upon.
378379
*/
379
- var popupRect, rectElem = ev.target;
380
+ let popupRect, rectElem = ev.target;
380381
while(rectElem){
381382
popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
382383
if(popupRect) break;
383384
rectElem = rectElem.parentNode;
384385
}
385386
if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
386
- var x = popupRect.left, y = popupRect.top;
387
+ let x = popupRect.left, y = popupRect.top;
387388
if(x<0) x = 0;
388389
if(y<0) y = 0;
389390
if(rectElem){
390391
/* Try to ensure that the popup's z-level is higher than this element's */
391392
const rz = window.getComputedStyle(rectElem).zIndex;
392
- var myZ;
393
+ let myZ;
393394
if(rz && !isNaN(+rz)){
394395
myZ = +rz + 1;
395396
}else{
396397
myZ = 10000/*guess!*/;
397398
}
@@ -416,11 +417,11 @@
416417
fch.popup.show(x, y);
417418
return false;
418419
};
419420
f.foreachElement = function(e){
420421
if(e.classList.contains('processed')) return;
421
- e.classList.add('processed');
422
+ e.classList.add('processed', 'help-buttonlet');
422423
e.$helpContent = [];
423424
/* We have to move all child nodes out of the way because we
424425
cannot hide TEXT nodes via CSS (which cannot select TEXT
425426
nodes). We have to do it in two steps to avoid invaliding
426427
the list during traversal. */
@@ -427,11 +428,11 @@
427428
e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
428429
e.$helpContent.forEach((ch)=>ch.remove());
429430
e.addEventListener('click', f.clickHandler, false);
430431
};
431432
}/*static init*/
432
- var elems;
433
+ let elems;
433434
if(!arguments.length){
434435
arguments[0] = '.help-buttonlet:not(.processed)';
435436
arguments.length = 1;
436437
}
437438
if(arguments.length){
@@ -443,11 +444,11 @@
443444
elems = arguments[0];
444445
}
445446
}
446447
if(elems) elems.forEach(f.foreachElement);
447448
},
448
-
449
+
449450
/**
450451
Sets up the given element as a "help buttonlet", adding the CSS
451452
class help-buttonlet to it. Any (optional) arguments after the
452453
first are appended to the element using fossil.dom.append(), so
453454
that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
465466
return elem;
466467
}
467468
}/*helpButtonlets*/;
468469
469470
F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
470
-
471471
})(window.fossil);
472472
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
55 from the default), the class "fossil-PopupWidget" is always set
56 in order to allow certain app-internal CSS to account for popup
57 windows in special cases.
58
59 .style: optional object of properties to copy directly into
60 the element's style object.
61
62 The options passed to this constructor get normalized into a
63 separate object which includes any default values for options not
64 provided by the caller. That object is available this the
65 resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161 Sidebar: showing/hiding the widget is, as is conventional for
162 this framework, done by removing/adding the 'hidden' CSS class
163 to it, so that class must be defined appropriately.
164 */
165 show: function(){
166 var x = undefined, y = undefined, showIt,
167 wasShown = !this.e.classList.contains('hidden');
168 if(2===arguments.length){
169 x = arguments[0];
170 y = arguments[1];
171 showIt = true;
@@ -344,11 +344,12 @@
344 - No arguments, which is equivalent to passing the string
345 ".help-buttonlet:not(.processed)".
346
347 Passing the same element(s) more than once is a no-op: during
348 initialization, each elements get the class'processed' added to
349 it, and any elements with that class are skipped.
 
350
351 All child nodes of a help buttonlet are removed from the button
352 during initialization and stashed away for use in a PopupWidget
353 when the botton is clicked.
354
@@ -374,24 +375,24 @@
374 calculate the resulting size, then move and/or resize it.
375
376 This algorithm/these heuristics can certainly be improved
377 upon.
378 */
379 var popupRect, rectElem = ev.target;
380 while(rectElem){
381 popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
382 if(popupRect) break;
383 rectElem = rectElem.parentNode;
384 }
385 if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
386 var x = popupRect.left, y = popupRect.top;
387 if(x<0) x = 0;
388 if(y<0) y = 0;
389 if(rectElem){
390 /* Try to ensure that the popup's z-level is higher than this element's */
391 const rz = window.getComputedStyle(rectElem).zIndex;
392 var myZ;
393 if(rz && !isNaN(+rz)){
394 myZ = +rz + 1;
395 }else{
396 myZ = 10000/*guess!*/;
397 }
@@ -416,11 +417,11 @@
416 fch.popup.show(x, y);
417 return false;
418 };
419 f.foreachElement = function(e){
420 if(e.classList.contains('processed')) return;
421 e.classList.add('processed');
422 e.$helpContent = [];
423 /* We have to move all child nodes out of the way because we
424 cannot hide TEXT nodes via CSS (which cannot select TEXT
425 nodes). We have to do it in two steps to avoid invaliding
426 the list during traversal. */
@@ -427,11 +428,11 @@
427 e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
428 e.$helpContent.forEach((ch)=>ch.remove());
429 e.addEventListener('click', f.clickHandler, false);
430 };
431 }/*static init*/
432 var elems;
433 if(!arguments.length){
434 arguments[0] = '.help-buttonlet:not(.processed)';
435 arguments.length = 1;
436 }
437 if(arguments.length){
@@ -443,11 +444,11 @@
443 elems = arguments[0];
444 }
445 }
446 if(elems) elems.forEach(f.foreachElement);
447 },
448
449 /**
450 Sets up the given element as a "help buttonlet", adding the CSS
451 class help-buttonlet to it. Any (optional) arguments after the
452 first are appended to the element using fossil.dom.append(), so
453 that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
465 return elem;
466 }
467 }/*helpButtonlets*/;
468
469 F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
470
471 })(window.fossil);
472
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
55 from the default), the class "fossil-PopupWidget" is always set
56 in order to allow certain app-internal CSS to account for popup
57 windows in special cases.
58
59 .style: optional object of properties to copy directly into
60 the element's style object.
61
62 The options passed to this constructor get normalized into a
63 separate object which includes any default values for options not
64 provided by the caller. That object is available this the
65 resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161 Sidebar: showing/hiding the widget is, as is conventional for
162 this framework, done by removing/adding the 'hidden' CSS class
163 to it, so that class must be defined appropriately.
164 */
165 show: function(){
166 let x = undefined, y = undefined, showIt,
167 wasShown = !this.e.classList.contains('hidden');
168 if(2===arguments.length){
169 x = arguments[0];
170 y = arguments[1];
171 showIt = true;
@@ -344,11 +344,12 @@
344 - No arguments, which is equivalent to passing the string
345 ".help-buttonlet:not(.processed)".
346
347 Passing the same element(s) more than once is a no-op: during
348 initialization, each elements get the class'processed' added to
349 it, and any elements with that class are skipped. Each element
350 gets the 'help-buttonlet' CSS class added to it.
351
352 All child nodes of a help buttonlet are removed from the button
353 during initialization and stashed away for use in a PopupWidget
354 when the botton is clicked.
355
@@ -374,24 +375,24 @@
375 calculate the resulting size, then move and/or resize it.
376
377 This algorithm/these heuristics can certainly be improved
378 upon.
379 */
380 let popupRect, rectElem = ev.target;
381 while(rectElem){
382 popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
383 if(popupRect) break;
384 rectElem = rectElem.parentNode;
385 }
386 if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
387 let x = popupRect.left, y = popupRect.top;
388 if(x<0) x = 0;
389 if(y<0) y = 0;
390 if(rectElem){
391 /* Try to ensure that the popup's z-level is higher than this element's */
392 const rz = window.getComputedStyle(rectElem).zIndex;
393 let myZ;
394 if(rz && !isNaN(+rz)){
395 myZ = +rz + 1;
396 }else{
397 myZ = 10000/*guess!*/;
398 }
@@ -416,11 +417,11 @@
417 fch.popup.show(x, y);
418 return false;
419 };
420 f.foreachElement = function(e){
421 if(e.classList.contains('processed')) return;
422 e.classList.add('processed', 'help-buttonlet');
423 e.$helpContent = [];
424 /* We have to move all child nodes out of the way because we
425 cannot hide TEXT nodes via CSS (which cannot select TEXT
426 nodes). We have to do it in two steps to avoid invaliding
427 the list during traversal. */
@@ -427,11 +428,11 @@
428 e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
429 e.$helpContent.forEach((ch)=>ch.remove());
430 e.addEventListener('click', f.clickHandler, false);
431 };
432 }/*static init*/
433 let elems;
434 if(!arguments.length){
435 arguments[0] = '.help-buttonlet:not(.processed)';
436 arguments.length = 1;
437 }
438 if(arguments.length){
@@ -443,11 +444,11 @@
444 elems = arguments[0];
445 }
446 }
447 if(elems) elems.forEach(f.foreachElement);
448 },
449
450 /**
451 Sets up the given element as a "help buttonlet", adding the CSS
452 class help-buttonlet to it. Any (optional) arguments after the
453 first are appended to the element using fossil.dom.append(), so
454 that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
466 return elem;
467 }
468 }/*helpButtonlets*/;
469
470 F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
 
471 })(window.fossil);
472
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
8282
)+'::' : (
8383
'' /* transient storage */
8484
)
8585
);
8686
87
+ /**
88
+ Proxy for custom events. Created on demand.
89
+ */
90
+ let events;
8791
/**
8892
A proxy for localStorage or sessionStorage or a
8993
page-instance-local proxy, if neither one is availble.
9094
9195
Which exact storage implementation is uses is unspecified, and
9296
apps must not rely on it.
9397
*/
9498
F.storage = {
9599
storageKeyPrefix: storageKeyPrefix,
100
+ addEventListener(...args){
101
+ events ??= new EventTarget()
102
+ return events.addEventListener(...args);
103
+ },
104
+ removeEventListener(...args){
105
+ events ??= new EventTarget()
106
+ return events.removeEventListener(...args);
107
+ },
96108
/** Sets the storage key k to value v, implicitly converting
97
- it to a string. */
98
- set: (k,v)=>$storage.setItem(storageKeyPrefix+k,v),
109
+ it to a string.
110
+
111
+ Fires a 'set' CustomEvent with a detail value in the form
112
+ {key, value} with the new value.
113
+ */
114
+ set: (k,v)=>{
115
+ $storage.setItem(storageKeyPrefix+k,v);
116
+ if( events ){
117
+ events.dispatchEvent(
118
+ new CustomEvent('set',{
119
+ detail: F.nu({
120
+ key: k, value: v
121
+ })
122
+ })
123
+ );
124
+ }
125
+ },
99126
/** Sets storage key k to JSON.stringify(v). */
100
- setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
127
+ setJSON: function(k,v){
128
+ return this.set(k,JSON.stringify(v));
129
+ },
101130
/** Returns the value for the given storage key, or
102131
dflt if the key is not found in the storage. */
103132
get: (k,dflt)=>$storageHolder.hasOwnProperty(
104133
storageKeyPrefix+k
105134
) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
121150
catch(e){return dflt}
122151
},
123152
/** Returns true if the storage contains the given key,
124153
else false. */
125154
contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
126
- /** Removes the given key from the storage. Returns this. */
155
+ /**
156
+ Removes the given key from the storage. Returns this.
157
+
158
+ Fires a 'remove' CustomEvent with a detail value in the form
159
+ {key}.
160
+ */
127161
remove: function(k){
128
- $storage.removeItem(storageKeyPrefix+k);
162
+ const kk = storageKeyPrefix+k;
163
+ if( events ){
164
+ const had = $storageHolder.hasOwnProperty(kk)
165
+ $storage.removeItem(kk);
166
+ if( had ){
167
+ events.dispatchEvent(
168
+ new CustomEvent('remove',{
169
+ detail: F.nu({key: k})
170
+ })
171
+ );
172
+ }
173
+ }else{
174
+ $storage.removeItem(kk);
175
+ }
129176
return this;
130177
},
131178
/** Clears ALL keys from the storage. Returns this. */
132179
clear: function(){
133180
this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
134181
return this;
135182
},
136
- /** Returns an array of all keys currently in the storage. */
137
- keys: ()=>Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix)),
183
+ /** Returns an array of all keys currently in the storage. If full
184
+ is true then the keys include the storage key prefix, else
185
+ they don't. It should default to false but does not for
186
+ historical compatibility. */
187
+ keys: function(full=true){
188
+ const li = Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix));
189
+ if( full ) return li;
190
+ const n = this.storageKeyPrefix.length;
191
+ return li.map(v=>v.substring(n));
192
+ },
138193
/** Returns true if this storage is transient (only available
139194
until the page is reloaded), indicating that fileStorage
140195
and sessionStorage are unavailable. */
141196
isTransient: ()=>$storageHolder!==$storage,
142197
/** Returns a symbolic name for the current storage mechanism. */
143198
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
82 )+'::' : (
83 '' /* transient storage */
84 )
85 );
86
 
 
 
 
87 /**
88 A proxy for localStorage or sessionStorage or a
89 page-instance-local proxy, if neither one is availble.
90
91 Which exact storage implementation is uses is unspecified, and
92 apps must not rely on it.
93 */
94 F.storage = {
95 storageKeyPrefix: storageKeyPrefix,
 
 
 
 
 
 
 
 
96 /** Sets the storage key k to value v, implicitly converting
97 it to a string. */
98 set: (k,v)=>$storage.setItem(storageKeyPrefix+k,v),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99 /** Sets storage key k to JSON.stringify(v). */
100 setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
 
 
101 /** Returns the value for the given storage key, or
102 dflt if the key is not found in the storage. */
103 get: (k,dflt)=>$storageHolder.hasOwnProperty(
104 storageKeyPrefix+k
105 ) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
121 catch(e){return dflt}
122 },
123 /** Returns true if the storage contains the given key,
124 else false. */
125 contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
126 /** Removes the given key from the storage. Returns this. */
 
 
 
 
 
127 remove: function(k){
128 $storage.removeItem(storageKeyPrefix+k);
 
 
 
 
 
 
 
 
 
 
 
 
 
129 return this;
130 },
131 /** Clears ALL keys from the storage. Returns this. */
132 clear: function(){
133 this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
134 return this;
135 },
136 /** Returns an array of all keys currently in the storage. */
137 keys: ()=>Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix)),
 
 
 
 
 
 
 
 
138 /** Returns true if this storage is transient (only available
139 until the page is reloaded), indicating that fileStorage
140 and sessionStorage are unavailable. */
141 isTransient: ()=>$storageHolder!==$storage,
142 /** Returns a symbolic name for the current storage mechanism. */
143
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
82 )+'::' : (
83 '' /* transient storage */
84 )
85 );
86
87 /**
88 Proxy for custom events. Created on demand.
89 */
90 let events;
91 /**
92 A proxy for localStorage or sessionStorage or a
93 page-instance-local proxy, if neither one is availble.
94
95 Which exact storage implementation is uses is unspecified, and
96 apps must not rely on it.
97 */
98 F.storage = {
99 storageKeyPrefix: storageKeyPrefix,
100 addEventListener(...args){
101 events ??= new EventTarget()
102 return events.addEventListener(...args);
103 },
104 removeEventListener(...args){
105 events ??= new EventTarget()
106 return events.removeEventListener(...args);
107 },
108 /** Sets the storage key k to value v, implicitly converting
109 it to a string.
110
111 Fires a 'set' CustomEvent with a detail value in the form
112 {key, value} with the new value.
113 */
114 set: (k,v)=>{
115 $storage.setItem(storageKeyPrefix+k,v);
116 if( events ){
117 events.dispatchEvent(
118 new CustomEvent('set',{
119 detail: F.nu({
120 key: k, value: v
121 })
122 })
123 );
124 }
125 },
126 /** Sets storage key k to JSON.stringify(v). */
127 setJSON: function(k,v){
128 return this.set(k,JSON.stringify(v));
129 },
130 /** Returns the value for the given storage key, or
131 dflt if the key is not found in the storage. */
132 get: (k,dflt)=>$storageHolder.hasOwnProperty(
133 storageKeyPrefix+k
134 ) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
150 catch(e){return dflt}
151 },
152 /** Returns true if the storage contains the given key,
153 else false. */
154 contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
155 /**
156 Removes the given key from the storage. Returns this.
157
158 Fires a 'remove' CustomEvent with a detail value in the form
159 {key}.
160 */
161 remove: function(k){
162 const kk = storageKeyPrefix+k;
163 if( events ){
164 const had = $storageHolder.hasOwnProperty(kk)
165 $storage.removeItem(kk);
166 if( had ){
167 events.dispatchEvent(
168 new CustomEvent('remove',{
169 detail: F.nu({key: k})
170 })
171 );
172 }
173 }else{
174 $storage.removeItem(kk);
175 }
176 return this;
177 },
178 /** Clears ALL keys from the storage. Returns this. */
179 clear: function(){
180 this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
181 return this;
182 },
183 /** Returns an array of all keys currently in the storage. If full
184 is true then the keys include the storage key prefix, else
185 they don't. It should default to false but does not for
186 historical compatibility. */
187 keys: function(full=true){
188 const li = Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix));
189 if( full ) return li;
190 const n = this.storageKeyPrefix.length;
191 return li.map(v=>v.substring(n));
192 },
193 /** Returns true if this storage is transient (only available
194 until the page is reloaded), indicating that fileStorage
195 and sessionStorage are unavailable. */
196 isTransient: ()=>$storageHolder!==$storage,
197 /** Returns a symbolic name for the current storage mechanism. */
198
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
77
/**
88
Creates a TabManager. If passed a truthy first argument, it is
99
passed to init(). If passed a truthy second argument, it must be
1010
an Object holding configuration options:
1111
12
- {
12
+ {
1313
tabAccessKeys: boolean (=true)
1414
If true, tab buttons are assigned "accesskey" values
1515
equal to their 1-based tab number.
1616
}
1717
*/
@@ -56,11 +56,11 @@
5656
/**
5757
Initializes the tabs associated with the given tab container
5858
(DOM element or selector for a single element). This must be
5959
called once before using any other member functions of a given
6060
instance, noting that the constructor will call this if it is
61
- passed an argument.
61
+ passed an argument.
6262
6363
The tab container must have an 'id' attribute. This function
6464
looks through the DOM for all elements which have
6565
data-tab-parent=thatId. For each one it creates a button to
6666
switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150150
e.target.$manager.switchToTab(e.target.$tab);
151151
};
152152
}
153153
tab = tabArg(tab);
154154
tab.remove();
155
+ tab.classList.add('hidden');
155156
D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
156157
const tabCount = this.e.tabBar.childNodes.length+1;
157158
const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
158159
const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
159160
D.append(this.e.tabBar,btn);
160161
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
7 /**
8 Creates a TabManager. If passed a truthy first argument, it is
9 passed to init(). If passed a truthy second argument, it must be
10 an Object holding configuration options:
11
12 {
13 tabAccessKeys: boolean (=true)
14 If true, tab buttons are assigned "accesskey" values
15 equal to their 1-based tab number.
16 }
17 */
@@ -56,11 +56,11 @@
56 /**
57 Initializes the tabs associated with the given tab container
58 (DOM element or selector for a single element). This must be
59 called once before using any other member functions of a given
60 instance, noting that the constructor will call this if it is
61 passed an argument.
62
63 The tab container must have an 'id' attribute. This function
64 looks through the DOM for all elements which have
65 data-tab-parent=thatId. For each one it creates a button to
66 switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150 e.target.$manager.switchToTab(e.target.$tab);
151 };
152 }
153 tab = tabArg(tab);
154 tab.remove();
 
155 D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
156 const tabCount = this.e.tabBar.childNodes.length+1;
157 const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
158 const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
159 D.append(this.e.tabBar,btn);
160
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
7 /**
8 Creates a TabManager. If passed a truthy first argument, it is
9 passed to init(). If passed a truthy second argument, it must be
10 an Object holding configuration options:
11
12 {
13 tabAccessKeys: boolean (=true)
14 If true, tab buttons are assigned "accesskey" values
15 equal to their 1-based tab number.
16 }
17 */
@@ -56,11 +56,11 @@
56 /**
57 Initializes the tabs associated with the given tab container
58 (DOM element or selector for a single element). This must be
59 called once before using any other member functions of a given
60 instance, noting that the constructor will call this if it is
61 passed an argument.
62
63 The tab container must have an 'id' attribute. This function
64 looks through the DOM for all elements which have
65 data-tab-parent=thatId. For each one it creates a button to
66 switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150 e.target.$manager.switchToTab(e.target.$tab);
151 };
152 }
153 tab = tabArg(tab);
154 tab.remove();
155 tab.classList.add('hidden');
156 D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
157 const tabCount = this.e.tabBar.childNodes.length+1;
158 const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
159 const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
160 D.append(this.e.tabBar,btn);
161
+6 -2
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
19311931
@ Also attachment "%h(zFilename)" to
19321932
}else{
19331933
@ Attachment "%h(zFilename)" to
19341934
}
19351935
objType |= OBJTYPE_ATTACHMENT;
1936
- switch( attachment_target_type(zTarget) ){
1936
+ switch( attachment_target_type(zTarget, 1) ){
19371937
case CFTYPE_FORUM:
19381938
if( g.perm.Hyperlink && g.anon.RdForum ){
19391939
@ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
19401940
}else{
19411941
@ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
19541954
}else{
19551955
@ tech note [%S(zTarget)]
19561956
}
19571957
break;
19581958
case CFTYPE_WIKI:
1959
- default /* historical behavior - assume wiki */:
19601959
if( g.perm.Hyperlink && g.anon.RdWiki ){
19611960
@ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
19621961
}else{
19631962
@ wiki page [%h(zTarget)]
19641963
}
1964
+ break;
1965
+ default:
1966
+ /* historical behavior is to assume wiki, but we can end up
1967
+ ** showing bogus links that way to stale attachments. */
1968
+ @ unknown artifact %h(zTarget)
19651969
}
19661970
@ added by
19671971
hyperlink_to_user(zUser,zDate," on");
19681972
hyperlink_to_date(zDate,".");
19691973
cnt++;
19701974
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
1954 }else{
1955 @ tech note [%S(zTarget)]
1956 }
1957 break;
1958 case CFTYPE_WIKI:
1959 default /* historical behavior - assume wiki */:
1960 if( g.perm.Hyperlink && g.anon.RdWiki ){
1961 @ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
1962 }else{
1963 @ wiki page [%h(zTarget)]
1964 }
 
 
 
 
 
1965 }
1966 @ added by
1967 hyperlink_to_user(zUser,zDate," on");
1968 hyperlink_to_date(zDate,".");
1969 cnt++;
1970
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget, 1) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
1954 }else{
1955 @ tech note [%S(zTarget)]
1956 }
1957 break;
1958 case CFTYPE_WIKI:
 
1959 if( g.perm.Hyperlink && g.anon.RdWiki ){
1960 @ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
1961 }else{
1962 @ wiki page [%h(zTarget)]
1963 }
1964 break;
1965 default:
1966 /* historical behavior is to assume wiki, but we can end up
1967 ** showing bogus links that way to stale attachments. */
1968 @ unknown artifact %h(zTarget)
1969 }
1970 @ added by
1971 hyperlink_to_user(zUser,zDate," on");
1972 hyperlink_to_date(zDate,".");
1973 cnt++;
1974
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225225
$(SRCDIR)/copybtn.js \
226226
$(SRCDIR)/default.css \
227227
$(SRCDIR)/diff.js \
228228
$(SRCDIR)/diff.tcl \
229229
$(SRCDIR)/forum.js \
230
+ $(SRCDIR)/fossil.attach.js \
230231
$(SRCDIR)/fossil.bootstrap.js \
231232
$(SRCDIR)/fossil.confirmer.js \
232233
$(SRCDIR)/fossil.copybutton.js \
233234
$(SRCDIR)/fossil.diff.js \
234235
$(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
275276
$(SRCDIR)/sounds/e.wav \
276277
$(SRCDIR)/sounds/f.wav \
277278
$(SRCDIR)/style.admin_log.css \
278279
$(SRCDIR)/style.chat.css \
279280
$(SRCDIR)/style.fileedit.css \
281
+ $(SRCDIR)/style.forum.css \
280282
$(SRCDIR)/style.pikchrshow.css \
281283
$(SRCDIR)/style.uvlist.css \
282284
$(SRCDIR)/style.wikiedit.css \
283285
$(SRCDIR)/tree.js \
284286
$(SRCDIR)/useredit.js \
285287
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225 $(SRCDIR)/copybtn.js \
226 $(SRCDIR)/default.css \
227 $(SRCDIR)/diff.js \
228 $(SRCDIR)/diff.tcl \
229 $(SRCDIR)/forum.js \
 
230 $(SRCDIR)/fossil.bootstrap.js \
231 $(SRCDIR)/fossil.confirmer.js \
232 $(SRCDIR)/fossil.copybutton.js \
233 $(SRCDIR)/fossil.diff.js \
234 $(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
275 $(SRCDIR)/sounds/e.wav \
276 $(SRCDIR)/sounds/f.wav \
277 $(SRCDIR)/style.admin_log.css \
278 $(SRCDIR)/style.chat.css \
279 $(SRCDIR)/style.fileedit.css \
 
280 $(SRCDIR)/style.pikchrshow.css \
281 $(SRCDIR)/style.uvlist.css \
282 $(SRCDIR)/style.wikiedit.css \
283 $(SRCDIR)/tree.js \
284 $(SRCDIR)/useredit.js \
285
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225 $(SRCDIR)/copybtn.js \
226 $(SRCDIR)/default.css \
227 $(SRCDIR)/diff.js \
228 $(SRCDIR)/diff.tcl \
229 $(SRCDIR)/forum.js \
230 $(SRCDIR)/fossil.attach.js \
231 $(SRCDIR)/fossil.bootstrap.js \
232 $(SRCDIR)/fossil.confirmer.js \
233 $(SRCDIR)/fossil.copybutton.js \
234 $(SRCDIR)/fossil.diff.js \
235 $(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
276 $(SRCDIR)/sounds/e.wav \
277 $(SRCDIR)/sounds/f.wav \
278 $(SRCDIR)/style.admin_log.css \
279 $(SRCDIR)/style.chat.css \
280 $(SRCDIR)/style.fileedit.css \
281 $(SRCDIR)/style.forum.css \
282 $(SRCDIR)/style.pikchrshow.css \
283 $(SRCDIR)/style.uvlist.css \
284 $(SRCDIR)/style.wikiedit.css \
285 $(SRCDIR)/tree.js \
286 $(SRCDIR)/useredit.js \
287
+4 -8
--- src/manifest.c
+++ src/manifest.c
@@ -1128,20 +1128,16 @@
11281128
md5sum_init();
11291129
if( !isRepeat ) g.parseCnt[p->type]++;
11301130
return p;
11311131
11321132
manifest_syntax_error:
1133
- {
1134
- char *zUuid = rid_to_uuid(rid);
1133
+ if(pErr!=0){
1134
+ char *zUuid = rid>0 ? rid_to_uuid(rid) : 0;
11351135
if( zUuid ){
1136
- if(pErr!=0){
1137
- blob_appendf(pErr, "artifact [%s] ", zUuid);
1138
- }
1136
+ blob_appendf(pErr, "artifact [%s] ", zUuid);
11391137
fossil_free(zUuid);
11401138
}
1141
- }
1142
- if(pErr!=0){
11431139
if( zErr ){
11441140
blob_appendf(pErr, "line %d: %s", lineNo, zErr);
11451141
}else{
11461142
blob_appendf(pErr, "unknown error on line %d", lineNo);
11471143
}
@@ -2646,11 +2642,11 @@
26462642
" WHERE target=%Q AND filename=%Q))"
26472643
" WHERE target=%Q AND filename=%Q",
26482644
p->zAttachTarget, p->zAttachName,
26492645
p->zAttachTarget, p->zAttachName
26502646
);
2651
- switch( attachment_target_type(p->zAttachTarget) ){
2647
+ switch( attachment_target_type(p->zAttachTarget, 1) ){
26522648
case 0:
26532649
/* It is possible that p->zAttachTarget is not yet in this
26542650
** copy of the repository. If we cannot identify it yet,
26552651
** generate a generic /artifact link to it instead of a
26562652
** type-specific link or an error message. */
26572653
--- src/manifest.c
+++ src/manifest.c
@@ -1128,20 +1128,16 @@
1128 md5sum_init();
1129 if( !isRepeat ) g.parseCnt[p->type]++;
1130 return p;
1131
1132 manifest_syntax_error:
1133 {
1134 char *zUuid = rid_to_uuid(rid);
1135 if( zUuid ){
1136 if(pErr!=0){
1137 blob_appendf(pErr, "artifact [%s] ", zUuid);
1138 }
1139 fossil_free(zUuid);
1140 }
1141 }
1142 if(pErr!=0){
1143 if( zErr ){
1144 blob_appendf(pErr, "line %d: %s", lineNo, zErr);
1145 }else{
1146 blob_appendf(pErr, "unknown error on line %d", lineNo);
1147 }
@@ -2646,11 +2642,11 @@
2646 " WHERE target=%Q AND filename=%Q))"
2647 " WHERE target=%Q AND filename=%Q",
2648 p->zAttachTarget, p->zAttachName,
2649 p->zAttachTarget, p->zAttachName
2650 );
2651 switch( attachment_target_type(p->zAttachTarget) ){
2652 case 0:
2653 /* It is possible that p->zAttachTarget is not yet in this
2654 ** copy of the repository. If we cannot identify it yet,
2655 ** generate a generic /artifact link to it instead of a
2656 ** type-specific link or an error message. */
2657
--- src/manifest.c
+++ src/manifest.c
@@ -1128,20 +1128,16 @@
1128 md5sum_init();
1129 if( !isRepeat ) g.parseCnt[p->type]++;
1130 return p;
1131
1132 manifest_syntax_error:
1133 if(pErr!=0){
1134 char *zUuid = rid>0 ? rid_to_uuid(rid) : 0;
1135 if( zUuid ){
1136 blob_appendf(pErr, "artifact [%s] ", zUuid);
 
 
1137 fossil_free(zUuid);
1138 }
 
 
1139 if( zErr ){
1140 blob_appendf(pErr, "line %d: %s", lineNo, zErr);
1141 }else{
1142 blob_appendf(pErr, "unknown error on line %d", lineNo);
1143 }
@@ -2646,11 +2642,11 @@
2642 " WHERE target=%Q AND filename=%Q))"
2643 " WHERE target=%Q AND filename=%Q",
2644 p->zAttachTarget, p->zAttachName,
2645 p->zAttachTarget, p->zAttachName
2646 );
2647 switch( attachment_target_type(p->zAttachTarget, 1) ){
2648 case 0:
2649 /* It is possible that p->zAttachTarget is not yet in this
2650 ** copy of the repository. If we cannot identify it yet,
2651 ** generate a generic /artifact link to it instead of a
2652 ** type-specific link or an error message. */
2653
+39 -3
--- src/markdown.md
+++ src/markdown.md
@@ -184,16 +184,52 @@
184184
>```
185185
> Character **^** is not part of a label, it is part of the syntax.
186186
> Both a footnote's text and a fragment to which a footnote applies
187187
> are subject to further interpretation as Markdown sources.
188188
189
+## Safe HTML ##
190
+
191
+> Markdown documents may contain raw HTML, filtered to allow the following:
192
+>
193
+> &lt;a&gt; &lt;abbr&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt;
194
+> &lt;b&gt; &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;caption&gt;
195
+> &lt;center&gt; &lt;cite&gt; &lt;code&gt; &lt;col&gt; &lt;colgroup&gt;
196
+> &lt;dd&gt; &lt;del&gt; &lt;details&gt; &lt;dfn&gt; &lt;div&gt; &lt;dl&gt;
197
+> &lt;dt&gt; &lt;em&gt; &lt;figcaption&gt; &lt;figure&gt; &lt;font&gt;
198
+> &lt;footer&gt; &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt;
199
+> &lt;h6&gt; &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;ins&gt;
200
+> &lt;kbd&gt; &lt;label&gt; &lt;li&gt; &lt;mark&gt; &lt;meter&gt; &lt;nav&gt;
201
+> &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;picture&gt; &lt;pre&gt;
202
+> &lt;progress&gt; &lt;q&gt; &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt;
203
+> &lt;source&gt; &lt;span&gt; &lt;strike&gt; &lt;strong&gt; &lt;sub&gt;
204
+> &lt;summary&gt; &lt;sup&gt; &lt;table&gt; &lt;tbody&gt; &lt;td&gt;
205
+> &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt; &lt;time&gt; &lt;title&gt; &lt;tr&gt;
206
+> &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt; &lt;wbr&gt; &lt;verbatim&gt;
207
+>
208
+> Any other tag is sent through to the output in a way that causes it to be
209
+> visibly called out as disallowed. This is the same whitelist as for
210
+> [Fossil Wiki](/wiki_rules) markup.
211
+>
212
+> Fossil-specific elements **&lt;nowiki&gt;** and **&lt;verbatim&gt;** disable
213
+> wiki and Markdown processing within their spans; see
214
+> [Wiki formatting rules](/wiki_rules) for details.
215
+>
216
+> Only a fixed set of attributes is permitted (for example **id**,
217
+> **class**, **style**, **href**, **src**, **srcset**, **alt**, **type**,
218
+> **datetime**, and **value**). Event handlers, **javascript:** URLs, and
219
+> similar constructs are stripped.
220
+>
221
+> Repository administrators may allow additional unsafe HTML (such as
222
+> **&lt;script&gt;** and **&lt;form&gt;**) in some contexts using the
223
+> [safe-html setting](/help/safe-html) on the Admin/Wiki page.
224
+
189225
## Miscellaneous ##
190226
191227
> * In-line images are made using **\!\[alt-text\]\(image-URL\)**.
192
-> * Use HTML for advanced formatting such as forms, noting that certain
193
-> tags are [disallowed in some contexts](/help/safe-html).
194
-> * **\<!--** HTML-style comments **-->** are supported.
228
+> * Use HTML for advanced formatting; see [Safe HTML](#safe-html) above.
229
+> * **\<!--** HTML-style comments **-->** are allowed through only when
230
+> the safe-HTML rules are bypassed.
195231
> * Escape special characters (ex: **\[** **\(** **\|** **\***)
196232
> using backslash (ex: **\\\[** **\\\(** **\\\|** **\\\***).
197233
> * A line consisting of **---**, **\*\*\***, or **\_\_\_** is a horizontal
198234
> rule. Spaces and extra **-**/**\***/**_** are allowed.
199235
> * Paragraphs enclosed in **\<html\>...\</html\>** is passed through unchanged.
200236
--- src/markdown.md
+++ src/markdown.md
@@ -184,16 +184,52 @@
184 >```
185 > Character **^** is not part of a label, it is part of the syntax.
186 > Both a footnote's text and a fragment to which a footnote applies
187 > are subject to further interpretation as Markdown sources.
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189 ## Miscellaneous ##
190
191 > * In-line images are made using **\!\[alt-text\]\(image-URL\)**.
192 > * Use HTML for advanced formatting such as forms, noting that certain
193 > tags are [disallowed in some contexts](/help/safe-html).
194 > * **\<!--** HTML-style comments **-->** are supported.
195 > * Escape special characters (ex: **\[** **\(** **\|** **\***)
196 > using backslash (ex: **\\\[** **\\\(** **\\\|** **\\\***).
197 > * A line consisting of **---**, **\*\*\***, or **\_\_\_** is a horizontal
198 > rule. Spaces and extra **-**/**\***/**_** are allowed.
199 > * Paragraphs enclosed in **\<html\>...\</html\>** is passed through unchanged.
200
--- src/markdown.md
+++ src/markdown.md
@@ -184,16 +184,52 @@
184 >```
185 > Character **^** is not part of a label, it is part of the syntax.
186 > Both a footnote's text and a fragment to which a footnote applies
187 > are subject to further interpretation as Markdown sources.
188
189 ## Safe HTML ##
190
191 > Markdown documents may contain raw HTML, filtered to allow the following:
192 >
193 > &lt;a&gt; &lt;abbr&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt;
194 > &lt;b&gt; &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;caption&gt;
195 > &lt;center&gt; &lt;cite&gt; &lt;code&gt; &lt;col&gt; &lt;colgroup&gt;
196 > &lt;dd&gt; &lt;del&gt; &lt;details&gt; &lt;dfn&gt; &lt;div&gt; &lt;dl&gt;
197 > &lt;dt&gt; &lt;em&gt; &lt;figcaption&gt; &lt;figure&gt; &lt;font&gt;
198 > &lt;footer&gt; &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt;
199 > &lt;h6&gt; &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;ins&gt;
200 > &lt;kbd&gt; &lt;label&gt; &lt;li&gt; &lt;mark&gt; &lt;meter&gt; &lt;nav&gt;
201 > &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;picture&gt; &lt;pre&gt;
202 > &lt;progress&gt; &lt;q&gt; &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt;
203 > &lt;source&gt; &lt;span&gt; &lt;strike&gt; &lt;strong&gt; &lt;sub&gt;
204 > &lt;summary&gt; &lt;sup&gt; &lt;table&gt; &lt;tbody&gt; &lt;td&gt;
205 > &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt; &lt;time&gt; &lt;title&gt; &lt;tr&gt;
206 > &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt; &lt;wbr&gt; &lt;verbatim&gt;
207 >
208 > Any other tag is sent through to the output in a way that causes it to be
209 > visibly called out as disallowed. This is the same whitelist as for
210 > [Fossil Wiki](/wiki_rules) markup.
211 >
212 > Fossil-specific elements **&lt;nowiki&gt;** and **&lt;verbatim&gt;** disable
213 > wiki and Markdown processing within their spans; see
214 > [Wiki formatting rules](/wiki_rules) for details.
215 >
216 > Only a fixed set of attributes is permitted (for example **id**,
217 > **class**, **style**, **href**, **src**, **srcset**, **alt**, **type**,
218 > **datetime**, and **value**). Event handlers, **javascript:** URLs, and
219 > similar constructs are stripped.
220 >
221 > Repository administrators may allow additional unsafe HTML (such as
222 > **&lt;script&gt;** and **&lt;form&gt;**) in some contexts using the
223 > [safe-html setting](/help/safe-html) on the Admin/Wiki page.
224
225 ## Miscellaneous ##
226
227 > * In-line images are made using **\!\[alt-text\]\(image-URL\)**.
228 > * Use HTML for advanced formatting; see [Safe HTML](#safe-html) above.
229 > * **\<!--** HTML-style comments **-->** are allowed through only when
230 > the safe-HTML rules are bypassed.
231 > * Escape special characters (ex: **\[** **\(** **\|** **\***)
232 > using backslash (ex: **\\\[** **\\\(** **\\\|** **\\\***).
233 > * A line consisting of **---**, **\*\*\***, or **\_\_\_** is a horizontal
234 > rule. Spaces and extra **-**/**\***/**_** are allowed.
235 > * Paragraphs enclosed in **\<html\>...\</html\>** is passed through unchanged.
236
+13 -3
--- src/stash.c
+++ src/stash.c
@@ -447,24 +447,34 @@
447447
}
448448
}else{
449449
Blob delta;
450450
int isOrigLink = file_islink(zOPath);
451451
db_ephemeral_blob(&q, 6, &delta);
452
- if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
453452
if( !isOrigLink != !isLink ){
453
+ if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
454454
diff_print_index(zNew, pCfg, 0);
455455
diff_print_filenames(zOrig, zNew, pCfg, 0);
456456
printf(DIFF_CANNOT_COMPUTE_SYMLINK);
457457
}else{
458
+ int isChanged = 0;
458459
content_get(rid, &a);
459460
blob_delta_apply(&a, &delta, &b);
460461
if( fBaseline ){
461
- diff_file_mem(&a, &b, zNew, pCfg);
462
- }else{
462
+ if( blob_compare(&a, &b) ){
463
+ isChanged = 1;
464
+ if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
465
+ diff_file_mem(&a, &b, zNew, pCfg);
466
+ }
467
+ }else if( !file_same_as_blob(&b, zOPath) ){
468
+ isChanged = 1;
469
+ if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
463470
pCfg->diffFlags ^= DIFF_INVERT;
464471
diff_file(&b, zOPath, zNew, pCfg, 0);
465472
pCfg->diffFlags ^= DIFF_INVERT;
473
+ }
474
+ if( !bWebpage && !isChanged && pCfg->diffFlags & DIFF_VERBOSE ){
475
+ fossil_print("UNCHANGED %s\n", zNew);
466476
}
467477
blob_reset(&a);
468478
blob_reset(&b);
469479
}
470480
blob_reset(&delta);
471481
--- src/stash.c
+++ src/stash.c
@@ -447,24 +447,34 @@
447 }
448 }else{
449 Blob delta;
450 int isOrigLink = file_islink(zOPath);
451 db_ephemeral_blob(&q, 6, &delta);
452 if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
453 if( !isOrigLink != !isLink ){
 
454 diff_print_index(zNew, pCfg, 0);
455 diff_print_filenames(zOrig, zNew, pCfg, 0);
456 printf(DIFF_CANNOT_COMPUTE_SYMLINK);
457 }else{
 
458 content_get(rid, &a);
459 blob_delta_apply(&a, &delta, &b);
460 if( fBaseline ){
461 diff_file_mem(&a, &b, zNew, pCfg);
462 }else{
 
 
 
 
 
 
463 pCfg->diffFlags ^= DIFF_INVERT;
464 diff_file(&b, zOPath, zNew, pCfg, 0);
465 pCfg->diffFlags ^= DIFF_INVERT;
 
 
 
466 }
467 blob_reset(&a);
468 blob_reset(&b);
469 }
470 blob_reset(&delta);
471
--- src/stash.c
+++ src/stash.c
@@ -447,24 +447,34 @@
447 }
448 }else{
449 Blob delta;
450 int isOrigLink = file_islink(zOPath);
451 db_ephemeral_blob(&q, 6, &delta);
 
452 if( !isOrigLink != !isLink ){
453 if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
454 diff_print_index(zNew, pCfg, 0);
455 diff_print_filenames(zOrig, zNew, pCfg, 0);
456 printf(DIFF_CANNOT_COMPUTE_SYMLINK);
457 }else{
458 int isChanged = 0;
459 content_get(rid, &a);
460 blob_delta_apply(&a, &delta, &b);
461 if( fBaseline ){
462 if( blob_compare(&a, &b) ){
463 isChanged = 1;
464 if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
465 diff_file_mem(&a, &b, zNew, pCfg);
466 }
467 }else if( !file_same_as_blob(&b, zOPath) ){
468 isChanged = 1;
469 if( !bWebpage ) fossil_print("CHANGED %s\n", zNew);
470 pCfg->diffFlags ^= DIFF_INVERT;
471 diff_file(&b, zOPath, zNew, pCfg, 0);
472 pCfg->diffFlags ^= DIFF_INVERT;
473 }
474 if( !bWebpage && !isChanged && pCfg->diffFlags & DIFF_VERBOSE ){
475 fossil_print("UNCHANGED %s\n", zNew);
476 }
477 blob_reset(&a);
478 blob_reset(&b);
479 }
480 blob_reset(&delta);
481
+17 -2
--- src/statrep.c
+++ src/statrep.c
@@ -76,10 +76,15 @@
7676
case 'f':
7777
case 'F':
7878
zRealType = "f";
7979
rc = *zRealType;
8080
break;
81
+ case 'h':
82
+ case 'H':
83
+ zRealType = "h";
84
+ rc = *zRealType;
85
+ break;
8186
case 'g':
8287
case 'G':
8388
zRealType = "g";
8489
rc = *zRealType;
8590
break;
@@ -117,10 +122,16 @@
117122
}
118123
if( zRealType==0 ){
119124
statsReportTimelineYFlag = "a";
120125
db_multi_exec("CREATE TEMP VIEW v_reports AS "
121126
"SELECT * FROM event WHERE %s", zTimeSpan/*safe-for-%s*/);
127
+ }else if( rc=='h' ){
128
+ statsReportTimelineYFlag = zRealType;
129
+ db_multi_exec("CREATE TEMP VIEW v_reports AS "
130
+ "SELECT * FROM event WHERE (type='f') "
131
+ " AND (comment GLOB 'Post:*') AND %s",
132
+ zTimeSpan/*safe-for-%s*/);
122133
}else if( rc!='n' && rc!='m' ){
123134
statsReportTimelineYFlag = zRealType;
124135
db_multi_exec("CREATE TEMP VIEW v_reports AS "
125136
"SELECT * FROM event WHERE (type GLOB %Q) AND %s",
126137
zRealType, zTimeSpan/*safe-for-%s*/);
@@ -154,10 +165,12 @@
154165
return "non-merge check-ins";
155166
case 'e':
156167
return "technotes";
157168
case 'f':
158169
return "forum posts";
170
+ case 'h':
171
+ return "forum threads";
159172
case 'w':
160173
return "wiki changes";
161174
case 't':
162175
return "ticket changes";
163176
case 'g':
@@ -855,16 +868,17 @@
855868
** * all (everything),
856869
** * ci (check-in)
857870
** * m (merge check-in),
858871
** * n (non-merge check-in)
859872
** * f (forum post)
873
+** * h (forum thread)
860874
** * w (wiki page change)
861875
** * t (ticket change)
862876
** * g (tag added or removed)
863877
** Defaulting to all event types.
864
-** from=DATETIME Consider only events after this timestamp (requires to)
865
-** to=DATETIME Consider only events before this timestamp (requires from)
878
+** from=DATETIME Consider only events after this time (requires to)
879
+** to=DATETIME Consider only events before this time (requires from)
866880
**
867881
**
868882
** The view-specific query parameters include:
869883
**
870884
** view=byweek:
@@ -894,10 +908,11 @@
894908
};
895909
static const char *const azType[] = {
896910
"a", "All Changes",
897911
"ci", "Check-ins",
898912
"f", "Forum Posts",
913
+ "h", "Forum Threads",
899914
"m", "Merge check-ins",
900915
"n", "Non-merge check-ins",
901916
"g", "Tags",
902917
"e", "Tech Notes",
903918
"t", "Tickets",
904919
--- src/statrep.c
+++ src/statrep.c
@@ -76,10 +76,15 @@
76 case 'f':
77 case 'F':
78 zRealType = "f";
79 rc = *zRealType;
80 break;
 
 
 
 
 
81 case 'g':
82 case 'G':
83 zRealType = "g";
84 rc = *zRealType;
85 break;
@@ -117,10 +122,16 @@
117 }
118 if( zRealType==0 ){
119 statsReportTimelineYFlag = "a";
120 db_multi_exec("CREATE TEMP VIEW v_reports AS "
121 "SELECT * FROM event WHERE %s", zTimeSpan/*safe-for-%s*/);
 
 
 
 
 
 
122 }else if( rc!='n' && rc!='m' ){
123 statsReportTimelineYFlag = zRealType;
124 db_multi_exec("CREATE TEMP VIEW v_reports AS "
125 "SELECT * FROM event WHERE (type GLOB %Q) AND %s",
126 zRealType, zTimeSpan/*safe-for-%s*/);
@@ -154,10 +165,12 @@
154 return "non-merge check-ins";
155 case 'e':
156 return "technotes";
157 case 'f':
158 return "forum posts";
 
 
159 case 'w':
160 return "wiki changes";
161 case 't':
162 return "ticket changes";
163 case 'g':
@@ -855,16 +868,17 @@
855 ** * all (everything),
856 ** * ci (check-in)
857 ** * m (merge check-in),
858 ** * n (non-merge check-in)
859 ** * f (forum post)
 
860 ** * w (wiki page change)
861 ** * t (ticket change)
862 ** * g (tag added or removed)
863 ** Defaulting to all event types.
864 ** from=DATETIME Consider only events after this timestamp (requires to)
865 ** to=DATETIME Consider only events before this timestamp (requires from)
866 **
867 **
868 ** The view-specific query parameters include:
869 **
870 ** view=byweek:
@@ -894,10 +908,11 @@
894 };
895 static const char *const azType[] = {
896 "a", "All Changes",
897 "ci", "Check-ins",
898 "f", "Forum Posts",
 
899 "m", "Merge check-ins",
900 "n", "Non-merge check-ins",
901 "g", "Tags",
902 "e", "Tech Notes",
903 "t", "Tickets",
904
--- src/statrep.c
+++ src/statrep.c
@@ -76,10 +76,15 @@
76 case 'f':
77 case 'F':
78 zRealType = "f";
79 rc = *zRealType;
80 break;
81 case 'h':
82 case 'H':
83 zRealType = "h";
84 rc = *zRealType;
85 break;
86 case 'g':
87 case 'G':
88 zRealType = "g";
89 rc = *zRealType;
90 break;
@@ -117,10 +122,16 @@
122 }
123 if( zRealType==0 ){
124 statsReportTimelineYFlag = "a";
125 db_multi_exec("CREATE TEMP VIEW v_reports AS "
126 "SELECT * FROM event WHERE %s", zTimeSpan/*safe-for-%s*/);
127 }else if( rc=='h' ){
128 statsReportTimelineYFlag = zRealType;
129 db_multi_exec("CREATE TEMP VIEW v_reports AS "
130 "SELECT * FROM event WHERE (type='f') "
131 " AND (comment GLOB 'Post:*') AND %s",
132 zTimeSpan/*safe-for-%s*/);
133 }else if( rc!='n' && rc!='m' ){
134 statsReportTimelineYFlag = zRealType;
135 db_multi_exec("CREATE TEMP VIEW v_reports AS "
136 "SELECT * FROM event WHERE (type GLOB %Q) AND %s",
137 zRealType, zTimeSpan/*safe-for-%s*/);
@@ -154,10 +165,12 @@
165 return "non-merge check-ins";
166 case 'e':
167 return "technotes";
168 case 'f':
169 return "forum posts";
170 case 'h':
171 return "forum threads";
172 case 'w':
173 return "wiki changes";
174 case 't':
175 return "ticket changes";
176 case 'g':
@@ -855,16 +868,17 @@
868 ** * all (everything),
869 ** * ci (check-in)
870 ** * m (merge check-in),
871 ** * n (non-merge check-in)
872 ** * f (forum post)
873 ** * h (forum thread)
874 ** * w (wiki page change)
875 ** * t (ticket change)
876 ** * g (tag added or removed)
877 ** Defaulting to all event types.
878 ** from=DATETIME Consider only events after this time (requires to)
879 ** to=DATETIME Consider only events before this time (requires from)
880 **
881 **
882 ** The view-specific query parameters include:
883 **
884 ** view=byweek:
@@ -894,10 +908,11 @@
908 };
909 static const char *const azType[] = {
910 "a", "All Changes",
911 "ci", "Check-ins",
912 "f", "Forum Posts",
913 "h", "Forum Threads",
914 "m", "Merge check-ins",
915 "n", "Non-merge check-ins",
916 "g", "Tags",
917 "e", "Tech Notes",
918 "t", "Tickets",
919
+22 -3
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384384
385385
/* Use this for the $current_page variable if it is not NULL. If it
386386
** is NULL then use g.zPath.
387387
*/
388388
static char *local_zCurrentPage = 0;
389
+static char *local_zCurrentFeature = 0;
389390
390391
/*
391392
** Set the desired $current_page to something other than g.zPath
392393
*/
393394
void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
419420
420421
/* Initialize the URL to its baseline */
421422
url = empty_blob;
422423
blob_appendf(&url, "%R/style.css");
423424
424
- /* If page-specific CSS exists for the current page, then append
425
- ** the pathname for the page-specific CSS. The default CSS is
425
+ /* If page- or feature-specific CSS exists for the current page,
426
+ ** then append the pathname for the page-specific CSS. The default
427
+ ** CSS is
426428
**
427429
** /style.css
428430
**
429431
** But for the "/wikiedit" page (to name but one example), we
430432
** append a path as follows:
@@ -432,14 +434,25 @@
432434
** /style.css/wikiedit
433435
**
434436
** The /style.css page (implemented below) will detect this extra "wikiedit"
435437
** path information and include the page-specific CSS along with the
436438
** default CSS when it delivers the page.
439
+ **
440
+ ** Prior to 2026-06-06, this only looked at zPage but /forum and
441
+ ** friends need a per-feature style, so it now falls back to
442
+ ** local_zCurrentFeature. The current mechanism cannot support both
443
+ ** concurrently in a single request.
437444
*/
438445
zBuiltin = mprintf("style.%s.css", zPage);
439446
if( builtin_file(zBuiltin,0)!=0 ){
440
- blob_appendf(&url, "/%s", zPage);
447
+ blob_appendf(&url, "/%t", zPage);
448
+ }else if( local_zCurrentFeature ){
449
+ fossil_free(zBuiltin);
450
+ zBuiltin = mprintf("style.%s.css", local_zCurrentFeature);
451
+ if( builtin_file(zBuiltin,0)!=0 ){
452
+ blob_appendf(&url, "/%t", local_zCurrentFeature);
453
+ }
441454
}
442455
fossil_free(zBuiltin);
443456
444457
/* Add query parameters that will change whenever the skin changes
445458
** or after any updates to the CSS files
@@ -727,10 +740,12 @@
727740
** style_init_th1_vars() because that uses Th_MaybeStore() instead to
728741
** allow webpage implementations to call this before style_header()
729742
** to override that "maybe" default with something better.
730743
*/
731744
void style_set_current_feature(const char* zFeature){
745
+ fossil_free( local_zCurrentFeature );
746
+ local_zCurrentFeature = fossil_strdup(zFeature);
732747
Th_Store("current_feature", zFeature);
733748
}
734749
735750
/*
736751
** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
12581273
"** Page-specific CSS for \"%s\"\n"
12591274
"***********************************************************/\n",
12601275
zPage);
12611276
blob_append(pOut, zBuiltin, nFile);
12621277
fossil_free(zFile);
1278
+ zFile = 0;
12631279
return;
12641280
}
12651281
/* Potential TODO: check for aliases/page groups. e.g. group all
12661282
** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
12671283
** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
14181434
** For administators, or if the test_env_enable setting is true, then
14191435
** details of the request environment are displayed. Otherwise, just
14201436
** the error message is shown.
14211437
**
14221438
** If zFormat is an empty string, then this is the /test-env page.
1439
+**
1440
+** If the resulting formatted error message is not empty then this
1441
+** function does not return.
14231442
*/
14241443
void webpage_error(const char *zFormat, ...){
14251444
int showAll = 0;
14261445
char *zErr = 0;
14271446
int isAuth = 0;
14281447
14291448
ADDED src/style.forum.css
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384
385 /* Use this for the $current_page variable if it is not NULL. If it
386 ** is NULL then use g.zPath.
387 */
388 static char *local_zCurrentPage = 0;
 
389
390 /*
391 ** Set the desired $current_page to something other than g.zPath
392 */
393 void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
419
420 /* Initialize the URL to its baseline */
421 url = empty_blob;
422 blob_appendf(&url, "%R/style.css");
423
424 /* If page-specific CSS exists for the current page, then append
425 ** the pathname for the page-specific CSS. The default CSS is
 
426 **
427 ** /style.css
428 **
429 ** But for the "/wikiedit" page (to name but one example), we
430 ** append a path as follows:
@@ -432,14 +434,25 @@
432 ** /style.css/wikiedit
433 **
434 ** The /style.css page (implemented below) will detect this extra "wikiedit"
435 ** path information and include the page-specific CSS along with the
436 ** default CSS when it delivers the page.
 
 
 
 
 
437 */
438 zBuiltin = mprintf("style.%s.css", zPage);
439 if( builtin_file(zBuiltin,0)!=0 ){
440 blob_appendf(&url, "/%s", zPage);
 
 
 
 
 
 
441 }
442 fossil_free(zBuiltin);
443
444 /* Add query parameters that will change whenever the skin changes
445 ** or after any updates to the CSS files
@@ -727,10 +740,12 @@
727 ** style_init_th1_vars() because that uses Th_MaybeStore() instead to
728 ** allow webpage implementations to call this before style_header()
729 ** to override that "maybe" default with something better.
730 */
731 void style_set_current_feature(const char* zFeature){
 
 
732 Th_Store("current_feature", zFeature);
733 }
734
735 /*
736 ** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
1258 "** Page-specific CSS for \"%s\"\n"
1259 "***********************************************************/\n",
1260 zPage);
1261 blob_append(pOut, zBuiltin, nFile);
1262 fossil_free(zFile);
 
1263 return;
1264 }
1265 /* Potential TODO: check for aliases/page groups. e.g. group all
1266 ** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
1267 ** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
1418 ** For administators, or if the test_env_enable setting is true, then
1419 ** details of the request environment are displayed. Otherwise, just
1420 ** the error message is shown.
1421 **
1422 ** If zFormat is an empty string, then this is the /test-env page.
 
 
 
1423 */
1424 void webpage_error(const char *zFormat, ...){
1425 int showAll = 0;
1426 char *zErr = 0;
1427 int isAuth = 0;
1428
1429 DDED src/style.forum.css
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384
385 /* Use this for the $current_page variable if it is not NULL. If it
386 ** is NULL then use g.zPath.
387 */
388 static char *local_zCurrentPage = 0;
389 static char *local_zCurrentFeature = 0;
390
391 /*
392 ** Set the desired $current_page to something other than g.zPath
393 */
394 void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
420
421 /* Initialize the URL to its baseline */
422 url = empty_blob;
423 blob_appendf(&url, "%R/style.css");
424
425 /* If page- or feature-specific CSS exists for the current page,
426 ** then append the pathname for the page-specific CSS. The default
427 ** CSS is
428 **
429 ** /style.css
430 **
431 ** But for the "/wikiedit" page (to name but one example), we
432 ** append a path as follows:
@@ -432,14 +434,25 @@
434 ** /style.css/wikiedit
435 **
436 ** The /style.css page (implemented below) will detect this extra "wikiedit"
437 ** path information and include the page-specific CSS along with the
438 ** default CSS when it delivers the page.
439 **
440 ** Prior to 2026-06-06, this only looked at zPage but /forum and
441 ** friends need a per-feature style, so it now falls back to
442 ** local_zCurrentFeature. The current mechanism cannot support both
443 ** concurrently in a single request.
444 */
445 zBuiltin = mprintf("style.%s.css", zPage);
446 if( builtin_file(zBuiltin,0)!=0 ){
447 blob_appendf(&url, "/%t", zPage);
448 }else if( local_zCurrentFeature ){
449 fossil_free(zBuiltin);
450 zBuiltin = mprintf("style.%s.css", local_zCurrentFeature);
451 if( builtin_file(zBuiltin,0)!=0 ){
452 blob_appendf(&url, "/%t", local_zCurrentFeature);
453 }
454 }
455 fossil_free(zBuiltin);
456
457 /* Add query parameters that will change whenever the skin changes
458 ** or after any updates to the CSS files
@@ -727,10 +740,12 @@
740 ** style_init_th1_vars() because that uses Th_MaybeStore() instead to
741 ** allow webpage implementations to call this before style_header()
742 ** to override that "maybe" default with something better.
743 */
744 void style_set_current_feature(const char* zFeature){
745 fossil_free( local_zCurrentFeature );
746 local_zCurrentFeature = fossil_strdup(zFeature);
747 Th_Store("current_feature", zFeature);
748 }
749
750 /*
751 ** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
1273 "** Page-specific CSS for \"%s\"\n"
1274 "***********************************************************/\n",
1275 zPage);
1276 blob_append(pOut, zBuiltin, nFile);
1277 fossil_free(zFile);
1278 zFile = 0;
1279 return;
1280 }
1281 /* Potential TODO: check for aliases/page groups. e.g. group all
1282 ** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
1283 ** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
1434 ** For administators, or if the test_env_enable setting is true, then
1435 ** details of the request environment are displayed. Otherwise, just
1436 ** the error message is shown.
1437 **
1438 ** If zFormat is an empty string, then this is the /test-env page.
1439 **
1440 ** If the resulting formatted error message is not empty then this
1441 ** function does not return.
1442 */
1443 void webpage_error(const char *zFormat, ...){
1444 int showAll = 0;
1445 char *zErr = 0;
1446 int isAuth = 0;
1447
1448 DDED src/style.forum.css
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,33 @@
1
+/* Styles specific to the forum family of pages */
2
+
3
+fieldset.forum-status-selection {
4
+ max-width: max-content;
5
+ border-radius: 0.5em;
6
+ padding: 0 0.5em;
7
+ margin-bottom: 0.35em;
8
+}
9
+
10
+body.forum .forumpost-single-controls button.draft:after {
11
+ /* Reply/Edit buttons on posts which have local draft edits. */
12
+ content: " [draft]";
13
+}
14
+
15
+/* .ForumPostEditor is the top container element used by the JS
16
+ ForumPostEditor class in fossil.page.forumpost.js. */
17
+.ForumPostEditor {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 1em;
21
+ padding: 0.5em;
22
+}
23
+.ForumPostEditor > .tab-bar{
24
+}
25
+.ForumPostEditor > .tab-container {
26
+}
27
+.ForumPostEditor > .tab-container > .tabs {
28
+ /*min-height: 10em;*/
29
+}
30
+.ForumPostEditor > .tab-container > .tabs > .tab-panel.debug {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 0
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,33 @@
1 /* Styles specific to the forum family of pages */
2
3 fieldset.forum-status-selection {
4 max-width: max-content;
5 border-radius: 0.5em;
6 padding: 0 0.5em;
7 margin-bottom: 0.35em;
8 }
9
10 body.forum .forumpost-single-controls button.draft:after {
11 /* Reply/Edit buttons on posts which have local draft edits. */
12 content: " [draft]";
13 }
14
15 /* .ForumPostEditor is the top container element used by the JS
16 ForumPostEditor class in fossil.page.forumpost.js. */
17 .ForumPostEditor {
18 display: flex;
19 flex-direction: column;
20 gap: 1em;
21 padding: 0.5em;
22 }
23 .ForumPostEditor > .tab-bar{
24 }
25 .ForumPostEditor > .tab-container {
26 }
27 .ForumPostEditor > .tab-container > .tabs {
28 /*min-height: 10em;*/
29 }
30 .ForumPostEditor > .tab-container > .tabs > .tab-panel.debug {
31 display: flex;
32 flex-direction: column;
33 gap: 0
--- src/timeline.c
+++ src/timeline.c
@@ -3538,10 +3538,21 @@
35383538
zFree = mprintf("[%S] Edit to wiki page \"%s\" (user: %s)",
35393539
zId, zComShort+1, zUserShort);
35403540
}
35413541
}else{
35423542
zFree = mprintf("[%S] %s%s", zId, zPrefix, zCom);
3543
+
3544
+ if( get_comment_format() & COMMENT_PRINT_SUMMARY ){
3545
+ char *z, *t;
3546
+ for(z=zFree; *z!='\0'; z++){
3547
+ if( *z=='\n' ){
3548
+ for(t=z+1; *t!='\0' && *t!='\n' && fossil_isspace(*t); t++){}
3549
+ if( *t=='\n' ) break;
3550
+ }
3551
+ }
3552
+ *z = '\0';
3553
+ }
35433554
}
35443555
35453556
if( zFormat ){
35463557
char *zEntry;
35473558
int nEntryLine = 0;
@@ -3766,10 +3777,11 @@
37663777
** PATH can be a file or a subdirectory.
37673778
** -q|--quiet Do not print notifications at the end of the timeline.
37683779
** -r|--reverse Show items in chronological order.
37693780
** -R REPO_FILE Specifies the repository db to use. Default is
37703781
** the current check-out's repository.
3782
+** -s|--summmary Truncate comments after the first blank line.
37713783
** --sql Show the SQL used to generate the timeline
37723784
** -t|--type TYPE Output items from the given types only, such as:
37733785
** ci = file commits only
37743786
** e = technical notes only
37753787
** f = forum posts only
@@ -3804,10 +3816,12 @@
38043816
const char *zFilePattern = 0;
38053817
const char *zFormat = 0;
38063818
const char *zBr = 0;
38073819
Blob treeName;
38083820
int showSql = 0;
3821
+ int bCommentGitStyle = 0;
3822
+ int oldComFmtFlags = get_comment_format();
38093823
38103824
verboseFlag = find_option("verbose","v", 0)!=0;
38113825
if( !verboseFlag){
38123826
verboseFlag = find_option("showfiles","f", 0)!=0; /* deprecated */
38133827
}
@@ -3816,10 +3830,13 @@
38163830
zWidth = find_option("width","W",1);
38173831
zType = find_option("type","t",1);
38183832
zUser = find_option("for-user","u",1);
38193833
zFilePattern = find_option("path","p",1);
38203834
zFormat = find_option("format","F",1);
3835
+ if( (bCommentGitStyle = (find_option("summary","s",0))!=0) ){
3836
+ g.comFmtFlags |= COMMENT_PRINT_SUMMARY;
3837
+ }
38213838
zBr = find_option("branch","b",1);
38223839
if( find_option("current-branch","c",0)!=0 ){
38233840
if( !g.localOpen ){
38243841
fossil_fatal("not within an open check-out");
38253842
}else{
@@ -4030,10 +4047,11 @@
40304047
}
40314048
db_prepare_blob(&q, &sql);
40324049
blob_reset(&sql);
40334050
print_timeline(&q, n, width, zFormat, verboseFlag);
40344051
db_finalize(&q);
4052
+ g.comFmtFlags = oldComFmtFlags;
40354053
}
40364054
40374055
/*
40384056
** WEBPAGE: thisdayinhistory
40394057
**
40404058
--- src/timeline.c
+++ src/timeline.c
@@ -3538,10 +3538,21 @@
3538 zFree = mprintf("[%S] Edit to wiki page \"%s\" (user: %s)",
3539 zId, zComShort+1, zUserShort);
3540 }
3541 }else{
3542 zFree = mprintf("[%S] %s%s", zId, zPrefix, zCom);
 
 
 
 
 
 
 
 
 
 
 
3543 }
3544
3545 if( zFormat ){
3546 char *zEntry;
3547 int nEntryLine = 0;
@@ -3766,10 +3777,11 @@
3766 ** PATH can be a file or a subdirectory.
3767 ** -q|--quiet Do not print notifications at the end of the timeline.
3768 ** -r|--reverse Show items in chronological order.
3769 ** -R REPO_FILE Specifies the repository db to use. Default is
3770 ** the current check-out's repository.
 
3771 ** --sql Show the SQL used to generate the timeline
3772 ** -t|--type TYPE Output items from the given types only, such as:
3773 ** ci = file commits only
3774 ** e = technical notes only
3775 ** f = forum posts only
@@ -3804,10 +3816,12 @@
3804 const char *zFilePattern = 0;
3805 const char *zFormat = 0;
3806 const char *zBr = 0;
3807 Blob treeName;
3808 int showSql = 0;
 
 
3809
3810 verboseFlag = find_option("verbose","v", 0)!=0;
3811 if( !verboseFlag){
3812 verboseFlag = find_option("showfiles","f", 0)!=0; /* deprecated */
3813 }
@@ -3816,10 +3830,13 @@
3816 zWidth = find_option("width","W",1);
3817 zType = find_option("type","t",1);
3818 zUser = find_option("for-user","u",1);
3819 zFilePattern = find_option("path","p",1);
3820 zFormat = find_option("format","F",1);
 
 
 
3821 zBr = find_option("branch","b",1);
3822 if( find_option("current-branch","c",0)!=0 ){
3823 if( !g.localOpen ){
3824 fossil_fatal("not within an open check-out");
3825 }else{
@@ -4030,10 +4047,11 @@
4030 }
4031 db_prepare_blob(&q, &sql);
4032 blob_reset(&sql);
4033 print_timeline(&q, n, width, zFormat, verboseFlag);
4034 db_finalize(&q);
 
4035 }
4036
4037 /*
4038 ** WEBPAGE: thisdayinhistory
4039 **
4040
--- src/timeline.c
+++ src/timeline.c
@@ -3538,10 +3538,21 @@
3538 zFree = mprintf("[%S] Edit to wiki page \"%s\" (user: %s)",
3539 zId, zComShort+1, zUserShort);
3540 }
3541 }else{
3542 zFree = mprintf("[%S] %s%s", zId, zPrefix, zCom);
3543
3544 if( get_comment_format() & COMMENT_PRINT_SUMMARY ){
3545 char *z, *t;
3546 for(z=zFree; *z!='\0'; z++){
3547 if( *z=='\n' ){
3548 for(t=z+1; *t!='\0' && *t!='\n' && fossil_isspace(*t); t++){}
3549 if( *t=='\n' ) break;
3550 }
3551 }
3552 *z = '\0';
3553 }
3554 }
3555
3556 if( zFormat ){
3557 char *zEntry;
3558 int nEntryLine = 0;
@@ -3766,10 +3777,11 @@
3777 ** PATH can be a file or a subdirectory.
3778 ** -q|--quiet Do not print notifications at the end of the timeline.
3779 ** -r|--reverse Show items in chronological order.
3780 ** -R REPO_FILE Specifies the repository db to use. Default is
3781 ** the current check-out's repository.
3782 ** -s|--summmary Truncate comments after the first blank line.
3783 ** --sql Show the SQL used to generate the timeline
3784 ** -t|--type TYPE Output items from the given types only, such as:
3785 ** ci = file commits only
3786 ** e = technical notes only
3787 ** f = forum posts only
@@ -3804,10 +3816,12 @@
3816 const char *zFilePattern = 0;
3817 const char *zFormat = 0;
3818 const char *zBr = 0;
3819 Blob treeName;
3820 int showSql = 0;
3821 int bCommentGitStyle = 0;
3822 int oldComFmtFlags = get_comment_format();
3823
3824 verboseFlag = find_option("verbose","v", 0)!=0;
3825 if( !verboseFlag){
3826 verboseFlag = find_option("showfiles","f", 0)!=0; /* deprecated */
3827 }
@@ -3816,10 +3830,13 @@
3830 zWidth = find_option("width","W",1);
3831 zType = find_option("type","t",1);
3832 zUser = find_option("for-user","u",1);
3833 zFilePattern = find_option("path","p",1);
3834 zFormat = find_option("format","F",1);
3835 if( (bCommentGitStyle = (find_option("summary","s",0))!=0) ){
3836 g.comFmtFlags |= COMMENT_PRINT_SUMMARY;
3837 }
3838 zBr = find_option("branch","b",1);
3839 if( find_option("current-branch","c",0)!=0 ){
3840 if( !g.localOpen ){
3841 fossil_fatal("not within an open check-out");
3842 }else{
@@ -4030,10 +4047,11 @@
4047 }
4048 db_prepare_blob(&q, &sql);
4049 blob_reset(&sql);
4050 print_timeline(&q, n, width, zFormat, verboseFlag);
4051 db_finalize(&q);
4052 g.comFmtFlags = oldComFmtFlags;
4053 }
4054
4055 /*
4056 ** WEBPAGE: thisdayinhistory
4057 **
4058
+11 -6
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749749
}
750750
}
751751
if( g.anon.NewTkt ){
752752
style_submenu_element("New Ticket", "%R/tktnew");
753753
}
754
+ zFullName = db_text(0,
755
+ "SELECT tkt_uuid FROM ticket"
756
+ " WHERE tkt_uuid GLOB '%q*'", zUuid);
754757
if( g.anon.ApndTkt && g.anon.Attach ){
755
- style_submenu_element("Attach", "%R/attachadd?tkt=%T&from=%R/tktview/%t",
756
- zUuid, zUuid);
758
+ style_submenu_element("Attach", "%R/attachadd?target=%T&from=%R/tktview/%t",
759
+ zFullName, zUuid);
757760
}
758761
if( P("plaintext") ){
759762
style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
760763
}else{
761764
style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
773776
}
774777
}
775778
if( !showTimeline && g.perm.Hyperlink ){
776779
style_submenu_element("Timeline", "%R/info/%T", zUuid);
777780
}
778
- zFullName = db_text(0,
779
- "SELECT tkt_uuid FROM ticket"
780
- " WHERE tkt_uuid GLOB '%q*'", zUuid);
781781
if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782782
ticket_init();
783783
initializeVariablesFromCGI();
784784
getAllTicketFields();
785785
initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789789
safe_html_context(DOCSRC_TICKET);
790790
Th_Render(zScript);
791791
if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792792
793793
if( zFullName ){
794
- attachment_list(zFullName, "<h2>Attachments:</h2>", 1);
794
+ char * z = mprintf(
795
+ "<h2><a href='%R/attachlist?tkt=%t'>Attachments</a>:</h2>",
796
+ zFullName
797
+ );
798
+ attachment_list(zFullName, z, 1);
799
+ fossil_free(z);
795800
}
796801
797802
builtin_fossil_js_bundle_or("dom", "storage", NULL);
798803
builtin_request_js("fossil.page.ticket.js");
799804
builtin_fulfill_js_requests();
800805
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749 }
750 }
751 if( g.anon.NewTkt ){
752 style_submenu_element("New Ticket", "%R/tktnew");
753 }
 
 
 
754 if( g.anon.ApndTkt && g.anon.Attach ){
755 style_submenu_element("Attach", "%R/attachadd?tkt=%T&from=%R/tktview/%t",
756 zUuid, zUuid);
757 }
758 if( P("plaintext") ){
759 style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
760 }else{
761 style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
773 }
774 }
775 if( !showTimeline && g.perm.Hyperlink ){
776 style_submenu_element("Timeline", "%R/info/%T", zUuid);
777 }
778 zFullName = db_text(0,
779 "SELECT tkt_uuid FROM ticket"
780 " WHERE tkt_uuid GLOB '%q*'", zUuid);
781 if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782 ticket_init();
783 initializeVariablesFromCGI();
784 getAllTicketFields();
785 initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789 safe_html_context(DOCSRC_TICKET);
790 Th_Render(zScript);
791 if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792
793 if( zFullName ){
794 attachment_list(zFullName, "<h2>Attachments:</h2>", 1);
 
 
 
 
 
795 }
796
797 builtin_fossil_js_bundle_or("dom", "storage", NULL);
798 builtin_request_js("fossil.page.ticket.js");
799 builtin_fulfill_js_requests();
800
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749 }
750 }
751 if( g.anon.NewTkt ){
752 style_submenu_element("New Ticket", "%R/tktnew");
753 }
754 zFullName = db_text(0,
755 "SELECT tkt_uuid FROM ticket"
756 " WHERE tkt_uuid GLOB '%q*'", zUuid);
757 if( g.anon.ApndTkt && g.anon.Attach ){
758 style_submenu_element("Attach", "%R/attachadd?target=%T&from=%R/tktview/%t",
759 zFullName, zUuid);
760 }
761 if( P("plaintext") ){
762 style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
763 }else{
764 style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
776 }
777 }
778 if( !showTimeline && g.perm.Hyperlink ){
779 style_submenu_element("Timeline", "%R/info/%T", zUuid);
780 }
 
 
 
781 if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782 ticket_init();
783 initializeVariablesFromCGI();
784 getAllTicketFields();
785 initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789 safe_html_context(DOCSRC_TICKET);
790 Th_Render(zScript);
791 if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792
793 if( zFullName ){
794 char * z = mprintf(
795 "<h2><a href='%R/attachlist?tkt=%t'>Attachments</a>:</h2>",
796 zFullName
797 );
798 attachment_list(zFullName, z, 1);
799 fossil_free(z);
800 }
801
802 builtin_fossil_js_bundle_or("dom", "storage", NULL);
803 builtin_request_js("fossil.page.ticket.js");
804 builtin_fulfill_js_requests();
805
+2 -2
--- src/util.c
+++ src/util.c
@@ -917,12 +917,12 @@
917917
unsigned char zStr[37];
918918
unsigned char *p = zStr;
919919
int i, k;
920920
921921
sqlite3_randomness(16, aBlob);
922
- aBlob[6] = (aBlob[6]&0x0f) + 0x40; /* Version byte: 0100 xxxx */
923
- aBlob[8] = (aBlob[8]&0x3f) + 0x80; /* Variant byte: 10xx xxxx */
922
+ aBlob[6] = (aBlob[6]&0x0f) | 0x40; /* Version byte: 0100 xxxx */
923
+ aBlob[8] = (aBlob[8]&0x3f) | 0x80; /* Variant byte: 10xx xxxx */
924924
925925
for(i=0, k=0x550; i<16; i++, k=k>>1){
926926
if( k&1 ){
927927
*p++ = '-'; /* Add a dash after byte 4, 6, 8, and 12 */
928928
}
929929
--- src/util.c
+++ src/util.c
@@ -917,12 +917,12 @@
917 unsigned char zStr[37];
918 unsigned char *p = zStr;
919 int i, k;
920
921 sqlite3_randomness(16, aBlob);
922 aBlob[6] = (aBlob[6]&0x0f) + 0x40; /* Version byte: 0100 xxxx */
923 aBlob[8] = (aBlob[8]&0x3f) + 0x80; /* Variant byte: 10xx xxxx */
924
925 for(i=0, k=0x550; i<16; i++, k=k>>1){
926 if( k&1 ){
927 *p++ = '-'; /* Add a dash after byte 4, 6, 8, and 12 */
928 }
929
--- src/util.c
+++ src/util.c
@@ -917,12 +917,12 @@
917 unsigned char zStr[37];
918 unsigned char *p = zStr;
919 int i, k;
920
921 sqlite3_randomness(16, aBlob);
922 aBlob[6] = (aBlob[6]&0x0f) | 0x40; /* Version byte: 0100 xxxx */
923 aBlob[8] = (aBlob[8]&0x3f) | 0x80; /* Variant byte: 10xx xxxx */
924
925 for(i=0, k=0x550; i<16; i++, k=k>>1){
926 if( k&1 ){
927 *p++ = '-'; /* Add a dash after byte 4, 6, 8, and 12 */
928 }
929
+11 -39
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626626
style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627627
}
628628
if( g.perm.Hyperlink ){
629629
style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630630
}
631
+ if( rid>0 && attach_user_may(rid, CFTYPE_WIKI) ){
632
+ style_submenu_element("Attach", "%R/attachadd?target=%T",
633
+ zPageName);
634
+ }
631635
}
632636
if( !isPopup ){
633637
style_set_current_page("%T?name=%T", g.zPath, zPageName);
634638
wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
635639
if( !noSubmenu ){
@@ -826,49 +830,15 @@
826830
** mtime order.
827831
*/
828832
static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
829833
int latestOnly,
830834
int nullIfEmpty){
831
- int i = 0;
832
- Stmt q = empty_Stmt;
833
- db_prepare(&q,
834
- "SELECT datetime(mtime), src, target, filename, isLatest,"
835
- " (SELECT uuid FROM blob WHERE rid=attachid) uuid"
836
- " FROM attachment"
837
- " WHERE target=%Q"
838
- " AND (isLatest OR %d)"
839
- " ORDER BY target, isLatest DESC, mtime DESC",
840
- pWiki->zWikiTitle, !latestOnly
841
- );
842
- while(SQLITE_ROW == db_step(&q)){
843
- const char * zTime = db_column_text(&q, 0);
844
- const char * zSrc = db_column_text(&q, 1);
845
- const char * zTarget = db_column_text(&q, 2);
846
- const char * zName = db_column_text(&q, 3);
847
- const int isLatest = db_column_int(&q, 4);
848
- const char * zUuid = db_column_text(&q, 5);
849
- if(!i++){
850
- CX("[");
851
- }else{
852
- CX(",");
853
- }
854
- CX("{");
855
- CX("\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
856
- "\"filename\": %!j, \"mtime\": %!j, \"isLatest\": %s}",
857
- zUuid, zSrc, zTarget,
858
- zName, zTime, isLatest ? "true" : "false");
859
- }
860
- db_finalize(&q);
861
- if(!i){
862
- if(nullIfEmpty){
863
- CX("null");
864
- }else{
865
- CX("[]");
866
- }
867
- }else{
868
- CX("]");
869
- }
835
+ Blob b = BLOB_INITIALIZER;
836
+ attachments_to_json(pWiki, &b, latestOnly,
837
+ nullIfEmpty ? -1 : 1);
838
+ CX("%b", &b);
839
+ blob_reset(&b);
870840
}
871841
872842
/*
873843
** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
874844
** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
11391109
**
11401110
** URL params:
11411111
**
11421112
** mimetype = the wiki page mimetype (determines rendering style)
11431113
** content = the wiki page content
1114
+**
1115
+** Responds with a partial HTML document.
11441116
*/
11451117
static void wiki_ajax_route_preview(void){
11461118
const char * zContent = P("content");
11471119
11481120
if( zContent==0 ){
11491121
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626 style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627 }
628 if( g.perm.Hyperlink ){
629 style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630 }
 
 
 
 
631 }
632 if( !isPopup ){
633 style_set_current_page("%T?name=%T", g.zPath, zPageName);
634 wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
635 if( !noSubmenu ){
@@ -826,49 +830,15 @@
826 ** mtime order.
827 */
828 static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
829 int latestOnly,
830 int nullIfEmpty){
831 int i = 0;
832 Stmt q = empty_Stmt;
833 db_prepare(&q,
834 "SELECT datetime(mtime), src, target, filename, isLatest,"
835 " (SELECT uuid FROM blob WHERE rid=attachid) uuid"
836 " FROM attachment"
837 " WHERE target=%Q"
838 " AND (isLatest OR %d)"
839 " ORDER BY target, isLatest DESC, mtime DESC",
840 pWiki->zWikiTitle, !latestOnly
841 );
842 while(SQLITE_ROW == db_step(&q)){
843 const char * zTime = db_column_text(&q, 0);
844 const char * zSrc = db_column_text(&q, 1);
845 const char * zTarget = db_column_text(&q, 2);
846 const char * zName = db_column_text(&q, 3);
847 const int isLatest = db_column_int(&q, 4);
848 const char * zUuid = db_column_text(&q, 5);
849 if(!i++){
850 CX("[");
851 }else{
852 CX(",");
853 }
854 CX("{");
855 CX("\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
856 "\"filename\": %!j, \"mtime\": %!j, \"isLatest\": %s}",
857 zUuid, zSrc, zTarget,
858 zName, zTime, isLatest ? "true" : "false");
859 }
860 db_finalize(&q);
861 if(!i){
862 if(nullIfEmpty){
863 CX("null");
864 }else{
865 CX("[]");
866 }
867 }else{
868 CX("]");
869 }
870 }
871
872 /*
873 ** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
874 ** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
1139 **
1140 ** URL params:
1141 **
1142 ** mimetype = the wiki page mimetype (determines rendering style)
1143 ** content = the wiki page content
 
 
1144 */
1145 static void wiki_ajax_route_preview(void){
1146 const char * zContent = P("content");
1147
1148 if( zContent==0 ){
1149
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626 style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627 }
628 if( g.perm.Hyperlink ){
629 style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630 }
631 if( rid>0 && attach_user_may(rid, CFTYPE_WIKI) ){
632 style_submenu_element("Attach", "%R/attachadd?target=%T",
633 zPageName);
634 }
635 }
636 if( !isPopup ){
637 style_set_current_page("%T?name=%T", g.zPath, zPageName);
638 wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
639 if( !noSubmenu ){
@@ -826,49 +830,15 @@
830 ** mtime order.
831 */
832 static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
833 int latestOnly,
834 int nullIfEmpty){
835 Blob b = BLOB_INITIALIZER;
836 attachments_to_json(pWiki, &b, latestOnly,
837 nullIfEmpty ? -1 : 1);
838 CX("%b", &b);
839 blob_reset(&b);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840 }
841
842 /*
843 ** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
844 ** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
1109 **
1110 ** URL params:
1111 **
1112 ** mimetype = the wiki page mimetype (determines rendering style)
1113 ** content = the wiki page content
1114 **
1115 ** Responds with a partial HTML document.
1116 */
1117 static void wiki_ajax_route_preview(void){
1118 const char * zContent = P("content");
1119
1120 if( zContent==0 ){
1121
+16 -15
--- src/wiki.wiki
+++ src/wiki.wiki
@@ -55,25 +55,26 @@
5555
<nowiki>[#anchor-name],</nowiki> providing you have added the necessary
5656
"&lt;a name='anchor-name'&gt;&lt;/a&gt;" tag to your wiki page.
5757
5858
6. <b>HTML.</b>
5959
The following standard HTML elements may be used:
60
- &lt;a&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt; &lt;b&gt;
61
- &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;center&gt; &lt;cite&gt;
62
- &lt;code&gt; &lt;col&gt; &lt;colgroup&gt; &lt;dd&gt;
63
- &lt;del&gt; &lt;dfn&gt;
64
- &lt;div&gt; &lt;dl&gt; &lt;dt&gt; &lt;em&gt; &lt;font&gt; &lt;footer&gt;
65
- &lt;ins&gt;
66
- &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt; &lt;h6&gt;
67
- &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;kbd&gt; &lt;li&gt;
68
- &lt;nav&gt; &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;pre&gt;
69
- &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt; &lt;span&gt;
70
- &lt;strike&gt; &lt;strong&gt; &lt;sub&gt; &lt;sup&gt; &lt;table&gt;
71
- &lt;tbody&gt; &lt;td&gt; &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt;
72
- &lt;title&gt; &lt;tr&gt; &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt;
73
- &lt;verbatim&gt;. There are two non-standard elements available:
74
- &lt;verbatim&gt; and &lt;nowiki&gt;. No other elements are allowed.
60
+ &lt;a&gt; &lt;abbr&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt;
61
+ &lt;b&gt; &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;caption&gt;
62
+ &lt;center&gt; &lt;cite&gt; &lt;code&gt; &lt;col&gt; &lt;colgroup&gt;
63
+ &lt;dd&gt; &lt;del&gt; &lt;details&gt; &lt;dfn&gt; &lt;div&gt; &lt;dl&gt;
64
+ &lt;dt&gt; &lt;em&gt; &lt;figcaption&gt; &lt;figure&gt; &lt;font&gt;
65
+ &lt;footer&gt; &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt;
66
+ &lt;h6&gt; &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;ins&gt;
67
+ &lt;kbd&gt; &lt;label&gt; &lt;li&gt; &lt;mark&gt; &lt;meter&gt; &lt;nav&gt;
68
+ &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;picture&gt; &lt;pre&gt;
69
+ &lt;progress&gt; &lt;q&gt; &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt;
70
+ &lt;source&gt; &lt;span&gt; &lt;strike&gt; &lt;strong&gt; &lt;sub&gt;
71
+ &lt;summary&gt; &lt;sup&gt; &lt;table&gt; &lt;tbody&gt; &lt;td&gt;
72
+ &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt; &lt;time&gt; &lt;title&gt; &lt;tr&gt;
73
+ &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt; &lt;wbr&gt; &lt;verbatim&gt;.
74
+ There are two non-standard elements available: &lt;verbatim&gt; and
75
+ &lt;nowiki&gt;. No other elements are allowed.
7576
All attributes are checked and only a few benign attributes are
7677
allowed on each element. In particular, any attributes that specify
7778
javascript or CSS are elided.
7879
7980
7. <b>Special Markup.</b>
8081
--- src/wiki.wiki
+++ src/wiki.wiki
@@ -55,25 +55,26 @@
55 <nowiki>[#anchor-name],</nowiki> providing you have added the necessary
56 "&lt;a name='anchor-name'&gt;&lt;/a&gt;" tag to your wiki page.
57
58 6. <b>HTML.</b>
59 The following standard HTML elements may be used:
60 &lt;a&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt; &lt;b&gt;
61 &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;center&gt; &lt;cite&gt;
62 &lt;code&gt; &lt;col&gt; &lt;colgroup&gt; &lt;dd&gt;
63 &lt;del&gt; &lt;dfn&gt;
64 &lt;div&gt; &lt;dl&gt; &lt;dt&gt; &lt;em&gt; &lt;font&gt; &lt;footer&gt;
65 &lt;ins&gt;
66 &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt; &lt;h6&gt;
67 &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;kbd&gt; &lt;li&gt;
68 &lt;nav&gt; &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;pre&gt;
69 &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt; &lt;span&gt;
70 &lt;strike&gt; &lt;strong&gt; &lt;sub&gt; &lt;sup&gt; &lt;table&gt;
71 &lt;tbody&gt; &lt;td&gt; &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt;
72 &lt;title&gt; &lt;tr&gt; &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt;
73 &lt;verbatim&gt;. There are two non-standard elements available:
74 &lt;verbatim&gt; and &lt;nowiki&gt;. No other elements are allowed.
 
75 All attributes are checked and only a few benign attributes are
76 allowed on each element. In particular, any attributes that specify
77 javascript or CSS are elided.
78
79 7. <b>Special Markup.</b>
80
--- src/wiki.wiki
+++ src/wiki.wiki
@@ -55,25 +55,26 @@
55 <nowiki>[#anchor-name],</nowiki> providing you have added the necessary
56 "&lt;a name='anchor-name'&gt;&lt;/a&gt;" tag to your wiki page.
57
58 6. <b>HTML.</b>
59 The following standard HTML elements may be used:
60 &lt;a&gt; &lt;abbr&gt; &lt;address&gt; &lt;article&gt; &lt;aside&gt;
61 &lt;b&gt; &lt;big&gt; &lt;blockquote&gt; &lt;br&gt; &lt;caption&gt;
62 &lt;center&gt; &lt;cite&gt; &lt;code&gt; &lt;col&gt; &lt;colgroup&gt;
63 &lt;dd&gt; &lt;del&gt; &lt;details&gt; &lt;dfn&gt; &lt;div&gt; &lt;dl&gt;
64 &lt;dt&gt; &lt;em&gt; &lt;figcaption&gt; &lt;figure&gt; &lt;font&gt;
65 &lt;footer&gt; &lt;h1&gt; &lt;h2&gt; &lt;h3&gt; &lt;h4&gt; &lt;h5&gt;
66 &lt;h6&gt; &lt;header&gt; &lt;hr&gt; &lt;i&gt; &lt;img&gt; &lt;ins&gt;
67 &lt;kbd&gt; &lt;label&gt; &lt;li&gt; &lt;mark&gt; &lt;meter&gt; &lt;nav&gt;
68 &lt;nobr&gt; &lt;nowiki&gt; &lt;ol&gt; &lt;p&gt; &lt;picture&gt; &lt;pre&gt;
69 &lt;progress&gt; &lt;q&gt; &lt;s&gt; &lt;samp&gt; &lt;section&gt; &lt;small&gt;
70 &lt;source&gt; &lt;span&gt; &lt;strike&gt; &lt;strong&gt; &lt;sub&gt;
71 &lt;summary&gt; &lt;sup&gt; &lt;table&gt; &lt;tbody&gt; &lt;td&gt;
72 &lt;tfoot&gt; &lt;th&gt; &lt;thead&gt; &lt;time&gt; &lt;title&gt; &lt;tr&gt;
73 &lt;tt&gt; &lt;u&gt; &lt;ul&gt; &lt;var&gt; &lt;wbr&gt; &lt;verbatim&gt;.
74 There are two non-standard elements available: &lt;verbatim&gt; and
75 &lt;nowiki&gt;. No other elements are allowed.
76 All attributes are checked and only a few benign attributes are
77 allowed on each element. In particular, any attributes that specify
78 javascript or CSS are elided.
79
80 7. <b>Special Markup.</b>
81
+115 -43
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -68,25 +68,37 @@
6868
ATTR_ALT,
6969
ATTR_BGCOLOR,
7070
ATTR_BORDER,
7171
ATTR_CELLPADDING,
7272
ATTR_CELLSPACING,
73
+ ATTR_CITE,
7374
ATTR_CLASS,
7475
ATTR_CLEAR,
7576
ATTR_COLOR,
7677
ATTR_COLSPAN,
7778
ATTR_COMPACT,
79
+ ATTR_DATETIME,
7880
ATTR_FACE,
81
+ ATTR_FOR,
7982
ATTR_HEIGHT,
83
+ ATTR_HIGH,
8084
ATTR_HREF,
8185
ATTR_HSPACE,
8286
ATTR_ID,
8387
ATTR_LINKS,
88
+ ATTR_LOW,
89
+ ATTR_MAX,
90
+ ATTR_MEDIA,
91
+ ATTR_MIN,
8492
ATTR_NAME,
93
+ ATTR_OPEN,
94
+ ATTR_OPTIMUM,
8595
ATTR_ROWSPAN,
8696
ATTR_SIZE,
97
+ ATTR_SIZES,
8798
ATTR_SRC,
99
+ ATTR_SRCSET,
88100
ATTR_START,
89101
ATTR_STYLE,
90102
ATTR_TARGET,
91103
ATTR_TITLE,
92104
ATTR_TYPE,
@@ -94,46 +106,48 @@
94106
ATTR_VALUE,
95107
ATTR_VSPACE,
96108
ATTR_WIDTH
97109
};
98110
99
-enum amsk_t {
100
- AMSK_ALIGN = 0x00000001,
101
- AMSK_ALT = 0x00000002,
102
- AMSK_BGCOLOR = 0x00000004,
103
- AMSK_BORDER = 0x00000008,
104
- AMSK_CELLPADDING = 0x00000010,
105
- AMSK_CELLSPACING = 0x00000020,
106
- AMSK_CLASS = 0x00000040,
107
- AMSK_CLEAR = 0x00000080,
108
- AMSK_COLOR = 0x00000100,
109
- AMSK_COLSPAN = 0x00000200,
110
- AMSK_COMPACT = 0x00000400,
111
- AMSK_FACE = 0x00000800,
112
- AMSK_HEIGHT = 0x00001000,
113
- AMSK_HREF = 0x00002000,
114
- AMSK_HSPACE = 0x00004000,
115
- AMSK_ID = 0x00008000,
116
- AMSK_LINKS = 0x00010000,
117
- AMSK_NAME = 0x00020000,
118
- AMSK_ROWSPAN = 0x00040000,
119
- AMSK_SIZE = 0x00080000,
120
- AMSK_SRC = 0x00100000,
121
- AMSK_START = 0x00200000,
122
- AMSK_STYLE = 0x00400000,
123
- AMSK_TARGET = 0x00800000,
124
- AMSK_TITLE = 0x01000000,
125
- AMSK_TYPE = 0x02000000,
126
- AMSK_VALIGN = 0x04000000,
127
- AMSK_VALUE = 0x08000000,
128
- AMSK_VSPACE = 0x10000000,
129
- AMSK_WIDTH = 0x20000000
130
-};
111
+typedef uint64_t amsk_t;
112
+#define AMSK_ALIGN ((amsk_t)1 << 0)
113
+#define AMSK_ALT ((amsk_t)1 << 1)
114
+#define AMSK_BGCOLOR ((amsk_t)1 << 2)
115
+#define AMSK_BORDER ((amsk_t)1 << 3)
116
+#define AMSK_CELLPADDING ((amsk_t)1 << 4)
117
+#define AMSK_CELLSPACING ((amsk_t)1 << 5)
118
+#define AMSK_CLASS ((amsk_t)1 << 6)
119
+#define AMSK_CLEAR ((amsk_t)1 << 7)
120
+#define AMSK_COLOR ((amsk_t)1 << 8)
121
+#define AMSK_COLSPAN ((amsk_t)1 << 9)
122
+#define AMSK_COMPACT ((amsk_t)1 << 10)
123
+#define AMSK_FACE ((amsk_t)1 << 11)
124
+#define AMSK_HEIGHT ((amsk_t)1 << 12)
125
+#define AMSK_HREF ((amsk_t)1 << 13)
126
+#define AMSK_HSPACE ((amsk_t)1 << 14)
127
+#define AMSK_ID ((amsk_t)1 << 15)
128
+#define AMSK_LINKS ((amsk_t)1 << 16)
129
+#define AMSK_NAME ((amsk_t)1 << 17)
130
+#define AMSK_OPEN ((amsk_t)1 << 18)
131
+#define AMSK_ROWSPAN ((amsk_t)1 << 19)
132
+#define AMSK_SIZE ((amsk_t)1 << 20)
133
+#define AMSK_SRC ((amsk_t)1 << 21)
134
+#define AMSK_START ((amsk_t)1 << 22)
135
+#define AMSK_STYLE ((amsk_t)1 << 23)
136
+#define AMSK_TARGET ((amsk_t)1 << 24)
137
+#define AMSK_TITLE ((amsk_t)1 << 25)
138
+#define AMSK_TYPE ((amsk_t)1 << 26)
139
+#define AMSK_VALIGN ((amsk_t)1 << 27)
140
+#define AMSK_VALUE ((amsk_t)1 << 28)
141
+#define AMSK_VSPACE ((amsk_t)1 << 29)
142
+#define AMSK_WIDTH ((amsk_t)1 << 30)
143
+#define AMSK_CITE ((amsk_t)1 << 31)
144
+#define AMSK_DATETIME ((amsk_t)1 << 32)
131145
132146
static const struct AllowedAttribute {
133147
const char *zName;
134
- unsigned int iMask;
148
+ amsk_t iMask;
135149
} aAttribute[] = {
136150
/* These indexes MUST line up with their
137151
corresponding allowed_attr_t enum values.
138152
*/
139153
{ 0, 0 },
@@ -141,25 +155,37 @@
141155
{ "alt", AMSK_ALT },
142156
{ "bgcolor", AMSK_BGCOLOR },
143157
{ "border", AMSK_BORDER },
144158
{ "cellpadding", AMSK_CELLPADDING },
145159
{ "cellspacing", AMSK_CELLSPACING },
160
+ { "cite", AMSK_CITE },
146161
{ "class", AMSK_CLASS },
147162
{ "clear", AMSK_CLEAR },
148163
{ "color", AMSK_COLOR },
149164
{ "colspan", AMSK_COLSPAN },
150165
{ "compact", AMSK_COMPACT },
166
+ { "datetime", AMSK_DATETIME },
151167
{ "face", AMSK_FACE },
168
+ { "for", 0 },
152169
{ "height", AMSK_HEIGHT },
170
+ { "high", 0 },
153171
{ "href", AMSK_HREF },
154172
{ "hspace", AMSK_HSPACE },
155173
{ "id", AMSK_ID },
156174
{ "links", AMSK_LINKS },
175
+ { "low", 0 },
176
+ { "max", 0 },
177
+ { "media", 0 },
178
+ { "min", 0 },
157179
{ "name", AMSK_NAME },
180
+ { "open", AMSK_OPEN },
181
+ { "optimum", 0 },
158182
{ "rowspan", AMSK_ROWSPAN },
159183
{ "size", AMSK_SIZE },
184
+ { "sizes", 0 },
160185
{ "src", AMSK_SRC },
186
+ { "srcset", 0 },
161187
{ "start", AMSK_START },
162188
{ "style", AMSK_STYLE },
163189
{ "target", AMSK_TARGET },
164190
{ "title", AMSK_TITLE },
165191
{ "type", AMSK_TYPE },
@@ -209,10 +235,11 @@
209235
MARKUP_HTML5_ASIDE,
210236
MARKUP_B,
211237
MARKUP_BIG,
212238
MARKUP_BLOCKQUOTE,
213239
MARKUP_BR,
240
+ MARKUP_CAPTION,
214241
MARKUP_CENTER,
215242
MARKUP_CITE,
216243
MARKUP_CODE,
217244
MARKUP_COL,
218245
MARKUP_COLGROUP,
@@ -222,10 +249,12 @@
222249
MARKUP_DFN,
223250
MARKUP_DIV,
224251
MARKUP_DL,
225252
MARKUP_DT,
226253
MARKUP_EM,
254
+ MARKUP_FIGCAPTION,
255
+ MARKUP_FIGURE,
227256
MARKUP_FONT,
228257
MARKUP_HTML5_FOOTER,
229258
MARKUP_H1,
230259
MARKUP_H2,
231260
MARKUP_H3,
@@ -236,21 +265,28 @@
236265
MARKUP_HR,
237266
MARKUP_I,
238267
MARKUP_IMG,
239268
MARKUP_INS,
240269
MARKUP_KBD,
270
+ MARKUP_LABEL,
241271
MARKUP_LI,
272
+ MARKUP_MARK,
273
+ MARKUP_METER,
242274
MARKUP_HTML5_NAV,
243275
MARKUP_NOBR,
244276
MARKUP_NOWIKI,
245277
MARKUP_OL,
246278
MARKUP_P,
279
+ MARKUP_PICTURE,
247280
MARKUP_PRE,
281
+ MARKUP_PROGRESS,
282
+ MARKUP_Q,
248283
MARKUP_S,
249284
MARKUP_SAMP,
250285
MARKUP_HTML5_SECTION,
251286
MARKUP_SMALL,
287
+ MARKUP_SOURCE,
252288
MARKUP_SPAN,
253289
MARKUP_STRIKE,
254290
MARKUP_STRONG,
255291
MARKUP_SUB,
256292
MARKUP_SUMMARY,
@@ -259,16 +295,18 @@
259295
MARKUP_TBODY,
260296
MARKUP_TD,
261297
MARKUP_TFOOT,
262298
MARKUP_TH,
263299
MARKUP_THEAD,
300
+ MARKUP_TIME,
264301
MARKUP_TITLE,
265302
MARKUP_TR,
266303
MARKUP_TT,
267304
MARKUP_U,
268305
MARKUP_UL,
269306
MARKUP_VAR,
307
+ MARKUP_WBR,
270308
MARKUP_VERBATIM
271309
};
272310
273311
/*
274312
** The various markup is divided into the following types:
@@ -299,11 +337,11 @@
299337
300338
static const struct AllowedMarkup {
301339
const char *zName; /* Name of the markup */
302340
char iCode; /* The MARKUP_* code */
303341
short int iType; /* The MUTYPE_* code */
304
- int allowedAttr; /* Allowed attributes on this markup */
342
+ amsk_t allowedAttr; /* Allowed attributes on this markup */
305343
} aMarkup[] = {
306344
{ 0, MARKUP_INVALID, 0, 0 },
307345
{ "a", MARKUP_A, MUTYPE_HYPERLINK,
308346
AMSK_HREF|AMSK_NAME|AMSK_CLASS|AMSK_TARGET|AMSK_STYLE|
309347
AMSK_TITLE},
@@ -316,10 +354,12 @@
316354
AMSK_ID|AMSK_CLASS|AMSK_STYLE },
317355
{ "b", MARKUP_B, MUTYPE_FONT, AMSK_STYLE },
318356
{ "big", MARKUP_BIG, MUTYPE_FONT, AMSK_STYLE },
319357
{ "blockquote", MARKUP_BLOCKQUOTE, MUTYPE_BLOCK, AMSK_STYLE },
320358
{ "br", MARKUP_BR, MUTYPE_SINGLE, AMSK_CLEAR },
359
+ { "caption", MARKUP_CAPTION, MUTYPE_BLOCK,
360
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
321361
{ "center", MARKUP_CENTER, MUTYPE_BLOCK, AMSK_STYLE },
322362
{ "cite", MARKUP_CITE, MUTYPE_FONT, AMSK_STYLE },
323363
{ "code", MARKUP_CODE, MUTYPE_FONT, AMSK_STYLE },
324364
{ "col", MARKUP_COL, MUTYPE_SINGLE,
325365
AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE },
@@ -326,18 +366,22 @@
326366
{ "colgroup", MARKUP_COLGROUP, MUTYPE_BLOCK,
327367
AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE},
328368
{ "dd", MARKUP_DD, MUTYPE_LI, AMSK_STYLE },
329369
{ "del", MARKUP_DEL, MUTYPE_FONT, AMSK_STYLE },
330370
{ "details", MARKUP_DETAILS, MUTYPE_BLOCK,
331
- AMSK_ID|AMSK_CLASS|AMSK_STYLE },
371
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_OPEN },
332372
{ "dfn", MARKUP_DFN, MUTYPE_FONT, AMSK_STYLE },
333373
{ "div", MARKUP_DIV, MUTYPE_BLOCK,
334374
AMSK_ID|AMSK_CLASS|AMSK_STYLE },
335375
{ "dl", MARKUP_DL, MUTYPE_LIST,
336376
AMSK_COMPACT|AMSK_STYLE },
337377
{ "dt", MARKUP_DT, MUTYPE_LI, AMSK_STYLE },
338378
{ "em", MARKUP_EM, MUTYPE_FONT, AMSK_STYLE },
379
+ { "figcaption", MARKUP_FIGCAPTION, MUTYPE_BLOCK,
380
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
381
+ { "figure", MARKUP_FIGURE, MUTYPE_BLOCK,
382
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
339383
{ "font", MARKUP_FONT, MUTYPE_FONT,
340384
AMSK_COLOR|AMSK_FACE|AMSK_SIZE|AMSK_STYLE },
341385
{ "footer", MARKUP_HTML5_FOOTER, MUTYPE_BLOCK,
342386
AMSK_ID|AMSK_CLASS|AMSK_STYLE },
343387
{ "h1", MARKUP_H1, MUTYPE_BLOCK,
@@ -358,29 +402,43 @@
358402
AMSK_ALIGN|AMSK_COLOR|AMSK_SIZE|AMSK_WIDTH|
359403
AMSK_STYLE|AMSK_CLASS },
360404
{ "i", MARKUP_I, MUTYPE_FONT, AMSK_STYLE },
361405
{ "img", MARKUP_IMG, MUTYPE_SINGLE,
362406
AMSK_ALIGN|AMSK_ALT|AMSK_BORDER|AMSK_HEIGHT|
363
- AMSK_HSPACE|AMSK_SRC|AMSK_VSPACE|AMSK_WIDTH|AMSK_STYLE },
407
+ AMSK_HSPACE|AMSK_SRC|AMSK_VSPACE|AMSK_WIDTH|AMSK_STYLE },
364408
{ "ins", MARKUP_INS, MUTYPE_FONT, AMSK_STYLE },
365409
{ "kbd", MARKUP_KBD, MUTYPE_FONT, AMSK_STYLE },
410
+ { "label", MARKUP_LABEL, MUTYPE_FONT,
411
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
366412
{ "li", MARKUP_LI, MUTYPE_LI,
367413
AMSK_TYPE|AMSK_VALUE|AMSK_STYLE },
414
+ { "mark", MARKUP_MARK, MUTYPE_FONT,
415
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
416
+ { "meter", MARKUP_METER, MUTYPE_FONT,
417
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_VALUE },
368418
{ "nav", MARKUP_HTML5_NAV, MUTYPE_BLOCK,
369419
AMSK_ID|AMSK_CLASS|AMSK_STYLE },
370420
{ "nobr", MARKUP_NOBR, MUTYPE_FONT, 0 },
371421
{ "nowiki", MARKUP_NOWIKI, MUTYPE_SPECIAL, 0 },
372422
{ "ol", MARKUP_OL, MUTYPE_LIST,
373423
AMSK_START|AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
374424
{ "p", MARKUP_P, MUTYPE_BLOCK,
375425
AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
426
+ { "picture", MARKUP_PICTURE, MUTYPE_BLOCK,
427
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE },
376428
{ "pre", MARKUP_PRE, MUTYPE_BLOCK, AMSK_STYLE },
429
+ { "progress", MARKUP_PROGRESS, MUTYPE_FONT,
430
+ AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_VALUE },
431
+ { "q", MARKUP_Q, MUTYPE_FONT,
432
+ AMSK_CLASS|AMSK_STYLE|AMSK_CITE },
377433
{ "s", MARKUP_S, MUTYPE_FONT, AMSK_STYLE },
378434
{ "samp", MARKUP_SAMP, MUTYPE_FONT, AMSK_STYLE },
379435
{ "section", MARKUP_HTML5_SECTION, MUTYPE_BLOCK,
380436
AMSK_ID|AMSK_CLASS|AMSK_STYLE },
381437
{ "small", MARKUP_SMALL, MUTYPE_FONT, AMSK_STYLE },
438
+ { "source", MARKUP_SOURCE, MUTYPE_SINGLE,
439
+ AMSK_TYPE|AMSK_ID|AMSK_CLASS|AMSK_STYLE },
382440
{ "span", MARKUP_SPAN, MUTYPE_BLOCK,
383441
AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
384442
{ "strike", MARKUP_STRIKE, MUTYPE_FONT, AMSK_STYLE },
385443
{ "strong", MARKUP_STRONG, MUTYPE_FONT, AMSK_STYLE },
386444
{ "sub", MARKUP_SUB, MUTYPE_FONT, AMSK_STYLE },
@@ -401,18 +459,21 @@
401459
{ "th", MARKUP_TH, MUTYPE_TD,
402460
AMSK_ALIGN|AMSK_BGCOLOR|AMSK_COLSPAN|
403461
AMSK_ROWSPAN|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
404462
{ "thead", MARKUP_THEAD, MUTYPE_BLOCK,
405463
AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
464
+ { "time", MARKUP_TIME, MUTYPE_FONT,
465
+ AMSK_DATETIME|AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_TITLE },
406466
{ "title", MARKUP_TITLE, MUTYPE_BLOCK, 0 },
407467
{ "tr", MARKUP_TR, MUTYPE_TR,
408468
AMSK_ALIGN|AMSK_BGCOLOR|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
409469
{ "tt", MARKUP_TT, MUTYPE_FONT, AMSK_STYLE },
410470
{ "u", MARKUP_U, MUTYPE_FONT, AMSK_STYLE },
411471
{ "ul", MARKUP_UL, MUTYPE_LIST,
412472
AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
413473
{ "var", MARKUP_VAR, MUTYPE_FONT, AMSK_STYLE },
474
+ { "wbr", MARKUP_WBR, MUTYPE_SINGLE, 0 },
414475
{ "verbatim", MARKUP_VERBATIM, MUTYPE_SPECIAL,
415476
AMSK_ID|AMSK_TYPE },
416477
};
417478
418479
void show_allowed_wiki_markup( void ){
@@ -816,23 +877,34 @@
816877
unsigned char iACode; /* ATTR_* */
817878
char *zValue; /* Argument to this attribute. Might be NULL */
818879
char cTerm; /* Original argument termination character */
819880
} aAttr[10];
820881
};
882
+
883
+/*
884
+** Return true if attribute iACode has already been parsed into p.
885
+*/
886
+static int markupHasAttr(const ParsedMarkup *p, int iACode){
887
+ int i;
888
+ for(i=0; i<p->nAttr; i++){
889
+ if( p->aAttr[i].iACode==iACode ) return 1;
890
+ }
891
+ return 0;
892
+}
821893
822894
/*
823895
** z[] is an HTML markup element - something that begins with '<'.
824896
** Parse this element into the p structure.
825897
**
826898
** The content of z[] might be modified by converting characters
827899
** to lowercase and by inserting some "\000" characters.
828900
*/
829
-static int parseMarkup(ParsedMarkup *p, char *z){
901
+static amsk_t parseMarkup(ParsedMarkup *p, char *z){
830902
int i, j, c;
831903
int iACode;
832904
char *zValue;
833
- int seen = 0;
905
+ amsk_t seen = 0;
834906
char zTag[100];
835907
836908
if( z[1]=='/' ){
837909
p->endTag = 1;
838910
i = 2;
@@ -868,11 +940,11 @@
868940
if( j<(int)sizeof(zTag)-1 ) zTag[j++] = fossil_tolower(z[i]);
869941
i++;
870942
}
871943
zTag[j] = 0;
872944
p->aAttr[p->nAttr].iACode = iACode = findAttr(zTag);
873
- attrOk = iACode!=0 && (seen & aAttribute[iACode].iMask)==0;
945
+ attrOk = iACode!=0 && !markupHasAttr(p, iACode);
874946
while( fossil_isspace(z[i]) ){ z++; }
875947
if( z[i]!='=' ){
876948
p->aAttr[p->nAttr].zValue = 0;
877949
p->aAttr[p->nAttr].cTerm = 0;
878950
c = 0;
@@ -904,11 +976,11 @@
904976
}
905977
}
906978
i++;
907979
}
908980
if( attrOk ){
909
- seen |= aAttribute[iACode].iMask;
981
+ if( aAttribute[iACode].iMask ) seen |= aAttribute[iACode].iMask;
910982
p->nAttr++;
911983
}
912984
while( fossil_isspace(z[i]) ){ i++; }
913985
if( z[i]==0 || z[i]=='>' || (z[i]=='/' && z[i+1]=='>') ) break;
914986
}
@@ -1712,17 +1784,17 @@
17121784
break;
17131785
}
17141786
case TOKEN_MARKUP: {
17151787
const char *zId;
17161788
int iDiv;
1717
- int mAttr = parseMarkup(&markup, z);
1789
+ (void)parseMarkup(&markup, z);
17181790
17191791
/* Convert <title> to <h1 align='center'> */
17201792
if( markup.iCode==MARKUP_TITLE && !p->inVerbatim ){
17211793
markup.iCode = MARKUP_H1;
17221794
markup.nAttr = 1;
1723
- markup.aAttr[0].iACode = AMSK_ALIGN;
1795
+ markup.aAttr[0].iACode = ATTR_ALIGN;
17241796
markup.aAttr[0].zValue = "center";
17251797
markup.aAttr[0].cTerm = 0;
17261798
}
17271799
17281800
/* Markup of the form </div id=ID> where there is a matching
@@ -1801,11 +1873,11 @@
18011873
popStackToTag(p, markup.iCode);
18021874
}else
18031875
18041876
/* Push <div> markup onto the stack together with the id=ID attribute.
18051877
*/
1806
- if( markup.iCode==MARKUP_DIV && (mAttr & ATTR_ID)!=0 ){
1878
+ if( markup.iCode==MARKUP_DIV && attributeValue(&markup, ATTR_ID)!=0 ){
18071879
pushStackWithId(p, markup.iCode, markupId(&markup),
18081880
(p->state & ALLOW_WIKI)!=0);
18091881
}else
18101882
18111883
/* Enter <verbatim> processing. With verbatim enabled, all other
18121884
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -68,25 +68,37 @@
68 ATTR_ALT,
69 ATTR_BGCOLOR,
70 ATTR_BORDER,
71 ATTR_CELLPADDING,
72 ATTR_CELLSPACING,
 
73 ATTR_CLASS,
74 ATTR_CLEAR,
75 ATTR_COLOR,
76 ATTR_COLSPAN,
77 ATTR_COMPACT,
 
78 ATTR_FACE,
 
79 ATTR_HEIGHT,
 
80 ATTR_HREF,
81 ATTR_HSPACE,
82 ATTR_ID,
83 ATTR_LINKS,
 
 
 
 
84 ATTR_NAME,
 
 
85 ATTR_ROWSPAN,
86 ATTR_SIZE,
 
87 ATTR_SRC,
 
88 ATTR_START,
89 ATTR_STYLE,
90 ATTR_TARGET,
91 ATTR_TITLE,
92 ATTR_TYPE,
@@ -94,46 +106,48 @@
94 ATTR_VALUE,
95 ATTR_VSPACE,
96 ATTR_WIDTH
97 };
98
99 enum amsk_t {
100 AMSK_ALIGN = 0x00000001,
101 AMSK_ALT = 0x00000002,
102 AMSK_BGCOLOR = 0x00000004,
103 AMSK_BORDER = 0x00000008,
104 AMSK_CELLPADDING = 0x00000010,
105 AMSK_CELLSPACING = 0x00000020,
106 AMSK_CLASS = 0x00000040,
107 AMSK_CLEAR = 0x00000080,
108 AMSK_COLOR = 0x00000100,
109 AMSK_COLSPAN = 0x00000200,
110 AMSK_COMPACT = 0x00000400,
111 AMSK_FACE = 0x00000800,
112 AMSK_HEIGHT = 0x00001000,
113 AMSK_HREF = 0x00002000,
114 AMSK_HSPACE = 0x00004000,
115 AMSK_ID = 0x00008000,
116 AMSK_LINKS = 0x00010000,
117 AMSK_NAME = 0x00020000,
118 AMSK_ROWSPAN = 0x00040000,
119 AMSK_SIZE = 0x00080000,
120 AMSK_SRC = 0x00100000,
121 AMSK_START = 0x00200000,
122 AMSK_STYLE = 0x00400000,
123 AMSK_TARGET = 0x00800000,
124 AMSK_TITLE = 0x01000000,
125 AMSK_TYPE = 0x02000000,
126 AMSK_VALIGN = 0x04000000,
127 AMSK_VALUE = 0x08000000,
128 AMSK_VSPACE = 0x10000000,
129 AMSK_WIDTH = 0x20000000
130 };
 
 
131
132 static const struct AllowedAttribute {
133 const char *zName;
134 unsigned int iMask;
135 } aAttribute[] = {
136 /* These indexes MUST line up with their
137 corresponding allowed_attr_t enum values.
138 */
139 { 0, 0 },
@@ -141,25 +155,37 @@
141 { "alt", AMSK_ALT },
142 { "bgcolor", AMSK_BGCOLOR },
143 { "border", AMSK_BORDER },
144 { "cellpadding", AMSK_CELLPADDING },
145 { "cellspacing", AMSK_CELLSPACING },
 
146 { "class", AMSK_CLASS },
147 { "clear", AMSK_CLEAR },
148 { "color", AMSK_COLOR },
149 { "colspan", AMSK_COLSPAN },
150 { "compact", AMSK_COMPACT },
 
151 { "face", AMSK_FACE },
 
152 { "height", AMSK_HEIGHT },
 
153 { "href", AMSK_HREF },
154 { "hspace", AMSK_HSPACE },
155 { "id", AMSK_ID },
156 { "links", AMSK_LINKS },
 
 
 
 
157 { "name", AMSK_NAME },
 
 
158 { "rowspan", AMSK_ROWSPAN },
159 { "size", AMSK_SIZE },
 
160 { "src", AMSK_SRC },
 
161 { "start", AMSK_START },
162 { "style", AMSK_STYLE },
163 { "target", AMSK_TARGET },
164 { "title", AMSK_TITLE },
165 { "type", AMSK_TYPE },
@@ -209,10 +235,11 @@
209 MARKUP_HTML5_ASIDE,
210 MARKUP_B,
211 MARKUP_BIG,
212 MARKUP_BLOCKQUOTE,
213 MARKUP_BR,
 
214 MARKUP_CENTER,
215 MARKUP_CITE,
216 MARKUP_CODE,
217 MARKUP_COL,
218 MARKUP_COLGROUP,
@@ -222,10 +249,12 @@
222 MARKUP_DFN,
223 MARKUP_DIV,
224 MARKUP_DL,
225 MARKUP_DT,
226 MARKUP_EM,
 
 
227 MARKUP_FONT,
228 MARKUP_HTML5_FOOTER,
229 MARKUP_H1,
230 MARKUP_H2,
231 MARKUP_H3,
@@ -236,21 +265,28 @@
236 MARKUP_HR,
237 MARKUP_I,
238 MARKUP_IMG,
239 MARKUP_INS,
240 MARKUP_KBD,
 
241 MARKUP_LI,
 
 
242 MARKUP_HTML5_NAV,
243 MARKUP_NOBR,
244 MARKUP_NOWIKI,
245 MARKUP_OL,
246 MARKUP_P,
 
247 MARKUP_PRE,
 
 
248 MARKUP_S,
249 MARKUP_SAMP,
250 MARKUP_HTML5_SECTION,
251 MARKUP_SMALL,
 
252 MARKUP_SPAN,
253 MARKUP_STRIKE,
254 MARKUP_STRONG,
255 MARKUP_SUB,
256 MARKUP_SUMMARY,
@@ -259,16 +295,18 @@
259 MARKUP_TBODY,
260 MARKUP_TD,
261 MARKUP_TFOOT,
262 MARKUP_TH,
263 MARKUP_THEAD,
 
264 MARKUP_TITLE,
265 MARKUP_TR,
266 MARKUP_TT,
267 MARKUP_U,
268 MARKUP_UL,
269 MARKUP_VAR,
 
270 MARKUP_VERBATIM
271 };
272
273 /*
274 ** The various markup is divided into the following types:
@@ -299,11 +337,11 @@
299
300 static const struct AllowedMarkup {
301 const char *zName; /* Name of the markup */
302 char iCode; /* The MARKUP_* code */
303 short int iType; /* The MUTYPE_* code */
304 int allowedAttr; /* Allowed attributes on this markup */
305 } aMarkup[] = {
306 { 0, MARKUP_INVALID, 0, 0 },
307 { "a", MARKUP_A, MUTYPE_HYPERLINK,
308 AMSK_HREF|AMSK_NAME|AMSK_CLASS|AMSK_TARGET|AMSK_STYLE|
309 AMSK_TITLE},
@@ -316,10 +354,12 @@
316 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
317 { "b", MARKUP_B, MUTYPE_FONT, AMSK_STYLE },
318 { "big", MARKUP_BIG, MUTYPE_FONT, AMSK_STYLE },
319 { "blockquote", MARKUP_BLOCKQUOTE, MUTYPE_BLOCK, AMSK_STYLE },
320 { "br", MARKUP_BR, MUTYPE_SINGLE, AMSK_CLEAR },
 
 
321 { "center", MARKUP_CENTER, MUTYPE_BLOCK, AMSK_STYLE },
322 { "cite", MARKUP_CITE, MUTYPE_FONT, AMSK_STYLE },
323 { "code", MARKUP_CODE, MUTYPE_FONT, AMSK_STYLE },
324 { "col", MARKUP_COL, MUTYPE_SINGLE,
325 AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE },
@@ -326,18 +366,22 @@
326 { "colgroup", MARKUP_COLGROUP, MUTYPE_BLOCK,
327 AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE},
328 { "dd", MARKUP_DD, MUTYPE_LI, AMSK_STYLE },
329 { "del", MARKUP_DEL, MUTYPE_FONT, AMSK_STYLE },
330 { "details", MARKUP_DETAILS, MUTYPE_BLOCK,
331 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
332 { "dfn", MARKUP_DFN, MUTYPE_FONT, AMSK_STYLE },
333 { "div", MARKUP_DIV, MUTYPE_BLOCK,
334 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
335 { "dl", MARKUP_DL, MUTYPE_LIST,
336 AMSK_COMPACT|AMSK_STYLE },
337 { "dt", MARKUP_DT, MUTYPE_LI, AMSK_STYLE },
338 { "em", MARKUP_EM, MUTYPE_FONT, AMSK_STYLE },
 
 
 
 
339 { "font", MARKUP_FONT, MUTYPE_FONT,
340 AMSK_COLOR|AMSK_FACE|AMSK_SIZE|AMSK_STYLE },
341 { "footer", MARKUP_HTML5_FOOTER, MUTYPE_BLOCK,
342 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
343 { "h1", MARKUP_H1, MUTYPE_BLOCK,
@@ -358,29 +402,43 @@
358 AMSK_ALIGN|AMSK_COLOR|AMSK_SIZE|AMSK_WIDTH|
359 AMSK_STYLE|AMSK_CLASS },
360 { "i", MARKUP_I, MUTYPE_FONT, AMSK_STYLE },
361 { "img", MARKUP_IMG, MUTYPE_SINGLE,
362 AMSK_ALIGN|AMSK_ALT|AMSK_BORDER|AMSK_HEIGHT|
363 AMSK_HSPACE|AMSK_SRC|AMSK_VSPACE|AMSK_WIDTH|AMSK_STYLE },
364 { "ins", MARKUP_INS, MUTYPE_FONT, AMSK_STYLE },
365 { "kbd", MARKUP_KBD, MUTYPE_FONT, AMSK_STYLE },
 
 
366 { "li", MARKUP_LI, MUTYPE_LI,
367 AMSK_TYPE|AMSK_VALUE|AMSK_STYLE },
 
 
 
 
368 { "nav", MARKUP_HTML5_NAV, MUTYPE_BLOCK,
369 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
370 { "nobr", MARKUP_NOBR, MUTYPE_FONT, 0 },
371 { "nowiki", MARKUP_NOWIKI, MUTYPE_SPECIAL, 0 },
372 { "ol", MARKUP_OL, MUTYPE_LIST,
373 AMSK_START|AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
374 { "p", MARKUP_P, MUTYPE_BLOCK,
375 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
 
 
376 { "pre", MARKUP_PRE, MUTYPE_BLOCK, AMSK_STYLE },
 
 
 
 
377 { "s", MARKUP_S, MUTYPE_FONT, AMSK_STYLE },
378 { "samp", MARKUP_SAMP, MUTYPE_FONT, AMSK_STYLE },
379 { "section", MARKUP_HTML5_SECTION, MUTYPE_BLOCK,
380 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
381 { "small", MARKUP_SMALL, MUTYPE_FONT, AMSK_STYLE },
 
 
382 { "span", MARKUP_SPAN, MUTYPE_BLOCK,
383 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
384 { "strike", MARKUP_STRIKE, MUTYPE_FONT, AMSK_STYLE },
385 { "strong", MARKUP_STRONG, MUTYPE_FONT, AMSK_STYLE },
386 { "sub", MARKUP_SUB, MUTYPE_FONT, AMSK_STYLE },
@@ -401,18 +459,21 @@
401 { "th", MARKUP_TH, MUTYPE_TD,
402 AMSK_ALIGN|AMSK_BGCOLOR|AMSK_COLSPAN|
403 AMSK_ROWSPAN|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
404 { "thead", MARKUP_THEAD, MUTYPE_BLOCK,
405 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
 
 
406 { "title", MARKUP_TITLE, MUTYPE_BLOCK, 0 },
407 { "tr", MARKUP_TR, MUTYPE_TR,
408 AMSK_ALIGN|AMSK_BGCOLOR|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
409 { "tt", MARKUP_TT, MUTYPE_FONT, AMSK_STYLE },
410 { "u", MARKUP_U, MUTYPE_FONT, AMSK_STYLE },
411 { "ul", MARKUP_UL, MUTYPE_LIST,
412 AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
413 { "var", MARKUP_VAR, MUTYPE_FONT, AMSK_STYLE },
 
414 { "verbatim", MARKUP_VERBATIM, MUTYPE_SPECIAL,
415 AMSK_ID|AMSK_TYPE },
416 };
417
418 void show_allowed_wiki_markup( void ){
@@ -816,23 +877,34 @@
816 unsigned char iACode; /* ATTR_* */
817 char *zValue; /* Argument to this attribute. Might be NULL */
818 char cTerm; /* Original argument termination character */
819 } aAttr[10];
820 };
 
 
 
 
 
 
 
 
 
 
 
821
822 /*
823 ** z[] is an HTML markup element - something that begins with '<'.
824 ** Parse this element into the p structure.
825 **
826 ** The content of z[] might be modified by converting characters
827 ** to lowercase and by inserting some "\000" characters.
828 */
829 static int parseMarkup(ParsedMarkup *p, char *z){
830 int i, j, c;
831 int iACode;
832 char *zValue;
833 int seen = 0;
834 char zTag[100];
835
836 if( z[1]=='/' ){
837 p->endTag = 1;
838 i = 2;
@@ -868,11 +940,11 @@
868 if( j<(int)sizeof(zTag)-1 ) zTag[j++] = fossil_tolower(z[i]);
869 i++;
870 }
871 zTag[j] = 0;
872 p->aAttr[p->nAttr].iACode = iACode = findAttr(zTag);
873 attrOk = iACode!=0 && (seen & aAttribute[iACode].iMask)==0;
874 while( fossil_isspace(z[i]) ){ z++; }
875 if( z[i]!='=' ){
876 p->aAttr[p->nAttr].zValue = 0;
877 p->aAttr[p->nAttr].cTerm = 0;
878 c = 0;
@@ -904,11 +976,11 @@
904 }
905 }
906 i++;
907 }
908 if( attrOk ){
909 seen |= aAttribute[iACode].iMask;
910 p->nAttr++;
911 }
912 while( fossil_isspace(z[i]) ){ i++; }
913 if( z[i]==0 || z[i]=='>' || (z[i]=='/' && z[i+1]=='>') ) break;
914 }
@@ -1712,17 +1784,17 @@
1712 break;
1713 }
1714 case TOKEN_MARKUP: {
1715 const char *zId;
1716 int iDiv;
1717 int mAttr = parseMarkup(&markup, z);
1718
1719 /* Convert <title> to <h1 align='center'> */
1720 if( markup.iCode==MARKUP_TITLE && !p->inVerbatim ){
1721 markup.iCode = MARKUP_H1;
1722 markup.nAttr = 1;
1723 markup.aAttr[0].iACode = AMSK_ALIGN;
1724 markup.aAttr[0].zValue = "center";
1725 markup.aAttr[0].cTerm = 0;
1726 }
1727
1728 /* Markup of the form </div id=ID> where there is a matching
@@ -1801,11 +1873,11 @@
1801 popStackToTag(p, markup.iCode);
1802 }else
1803
1804 /* Push <div> markup onto the stack together with the id=ID attribute.
1805 */
1806 if( markup.iCode==MARKUP_DIV && (mAttr & ATTR_ID)!=0 ){
1807 pushStackWithId(p, markup.iCode, markupId(&markup),
1808 (p->state & ALLOW_WIKI)!=0);
1809 }else
1810
1811 /* Enter <verbatim> processing. With verbatim enabled, all other
1812
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -68,25 +68,37 @@
68 ATTR_ALT,
69 ATTR_BGCOLOR,
70 ATTR_BORDER,
71 ATTR_CELLPADDING,
72 ATTR_CELLSPACING,
73 ATTR_CITE,
74 ATTR_CLASS,
75 ATTR_CLEAR,
76 ATTR_COLOR,
77 ATTR_COLSPAN,
78 ATTR_COMPACT,
79 ATTR_DATETIME,
80 ATTR_FACE,
81 ATTR_FOR,
82 ATTR_HEIGHT,
83 ATTR_HIGH,
84 ATTR_HREF,
85 ATTR_HSPACE,
86 ATTR_ID,
87 ATTR_LINKS,
88 ATTR_LOW,
89 ATTR_MAX,
90 ATTR_MEDIA,
91 ATTR_MIN,
92 ATTR_NAME,
93 ATTR_OPEN,
94 ATTR_OPTIMUM,
95 ATTR_ROWSPAN,
96 ATTR_SIZE,
97 ATTR_SIZES,
98 ATTR_SRC,
99 ATTR_SRCSET,
100 ATTR_START,
101 ATTR_STYLE,
102 ATTR_TARGET,
103 ATTR_TITLE,
104 ATTR_TYPE,
@@ -94,46 +106,48 @@
106 ATTR_VALUE,
107 ATTR_VSPACE,
108 ATTR_WIDTH
109 };
110
111 typedef uint64_t amsk_t;
112 #define AMSK_ALIGN ((amsk_t)1 << 0)
113 #define AMSK_ALT ((amsk_t)1 << 1)
114 #define AMSK_BGCOLOR ((amsk_t)1 << 2)
115 #define AMSK_BORDER ((amsk_t)1 << 3)
116 #define AMSK_CELLPADDING ((amsk_t)1 << 4)
117 #define AMSK_CELLSPACING ((amsk_t)1 << 5)
118 #define AMSK_CLASS ((amsk_t)1 << 6)
119 #define AMSK_CLEAR ((amsk_t)1 << 7)
120 #define AMSK_COLOR ((amsk_t)1 << 8)
121 #define AMSK_COLSPAN ((amsk_t)1 << 9)
122 #define AMSK_COMPACT ((amsk_t)1 << 10)
123 #define AMSK_FACE ((amsk_t)1 << 11)
124 #define AMSK_HEIGHT ((amsk_t)1 << 12)
125 #define AMSK_HREF ((amsk_t)1 << 13)
126 #define AMSK_HSPACE ((amsk_t)1 << 14)
127 #define AMSK_ID ((amsk_t)1 << 15)
128 #define AMSK_LINKS ((amsk_t)1 << 16)
129 #define AMSK_NAME ((amsk_t)1 << 17)
130 #define AMSK_OPEN ((amsk_t)1 << 18)
131 #define AMSK_ROWSPAN ((amsk_t)1 << 19)
132 #define AMSK_SIZE ((amsk_t)1 << 20)
133 #define AMSK_SRC ((amsk_t)1 << 21)
134 #define AMSK_START ((amsk_t)1 << 22)
135 #define AMSK_STYLE ((amsk_t)1 << 23)
136 #define AMSK_TARGET ((amsk_t)1 << 24)
137 #define AMSK_TITLE ((amsk_t)1 << 25)
138 #define AMSK_TYPE ((amsk_t)1 << 26)
139 #define AMSK_VALIGN ((amsk_t)1 << 27)
140 #define AMSK_VALUE ((amsk_t)1 << 28)
141 #define AMSK_VSPACE ((amsk_t)1 << 29)
142 #define AMSK_WIDTH ((amsk_t)1 << 30)
143 #define AMSK_CITE ((amsk_t)1 << 31)
144 #define AMSK_DATETIME ((amsk_t)1 << 32)
145
146 static const struct AllowedAttribute {
147 const char *zName;
148 amsk_t iMask;
149 } aAttribute[] = {
150 /* These indexes MUST line up with their
151 corresponding allowed_attr_t enum values.
152 */
153 { 0, 0 },
@@ -141,25 +155,37 @@
155 { "alt", AMSK_ALT },
156 { "bgcolor", AMSK_BGCOLOR },
157 { "border", AMSK_BORDER },
158 { "cellpadding", AMSK_CELLPADDING },
159 { "cellspacing", AMSK_CELLSPACING },
160 { "cite", AMSK_CITE },
161 { "class", AMSK_CLASS },
162 { "clear", AMSK_CLEAR },
163 { "color", AMSK_COLOR },
164 { "colspan", AMSK_COLSPAN },
165 { "compact", AMSK_COMPACT },
166 { "datetime", AMSK_DATETIME },
167 { "face", AMSK_FACE },
168 { "for", 0 },
169 { "height", AMSK_HEIGHT },
170 { "high", 0 },
171 { "href", AMSK_HREF },
172 { "hspace", AMSK_HSPACE },
173 { "id", AMSK_ID },
174 { "links", AMSK_LINKS },
175 { "low", 0 },
176 { "max", 0 },
177 { "media", 0 },
178 { "min", 0 },
179 { "name", AMSK_NAME },
180 { "open", AMSK_OPEN },
181 { "optimum", 0 },
182 { "rowspan", AMSK_ROWSPAN },
183 { "size", AMSK_SIZE },
184 { "sizes", 0 },
185 { "src", AMSK_SRC },
186 { "srcset", 0 },
187 { "start", AMSK_START },
188 { "style", AMSK_STYLE },
189 { "target", AMSK_TARGET },
190 { "title", AMSK_TITLE },
191 { "type", AMSK_TYPE },
@@ -209,10 +235,11 @@
235 MARKUP_HTML5_ASIDE,
236 MARKUP_B,
237 MARKUP_BIG,
238 MARKUP_BLOCKQUOTE,
239 MARKUP_BR,
240 MARKUP_CAPTION,
241 MARKUP_CENTER,
242 MARKUP_CITE,
243 MARKUP_CODE,
244 MARKUP_COL,
245 MARKUP_COLGROUP,
@@ -222,10 +249,12 @@
249 MARKUP_DFN,
250 MARKUP_DIV,
251 MARKUP_DL,
252 MARKUP_DT,
253 MARKUP_EM,
254 MARKUP_FIGCAPTION,
255 MARKUP_FIGURE,
256 MARKUP_FONT,
257 MARKUP_HTML5_FOOTER,
258 MARKUP_H1,
259 MARKUP_H2,
260 MARKUP_H3,
@@ -236,21 +265,28 @@
265 MARKUP_HR,
266 MARKUP_I,
267 MARKUP_IMG,
268 MARKUP_INS,
269 MARKUP_KBD,
270 MARKUP_LABEL,
271 MARKUP_LI,
272 MARKUP_MARK,
273 MARKUP_METER,
274 MARKUP_HTML5_NAV,
275 MARKUP_NOBR,
276 MARKUP_NOWIKI,
277 MARKUP_OL,
278 MARKUP_P,
279 MARKUP_PICTURE,
280 MARKUP_PRE,
281 MARKUP_PROGRESS,
282 MARKUP_Q,
283 MARKUP_S,
284 MARKUP_SAMP,
285 MARKUP_HTML5_SECTION,
286 MARKUP_SMALL,
287 MARKUP_SOURCE,
288 MARKUP_SPAN,
289 MARKUP_STRIKE,
290 MARKUP_STRONG,
291 MARKUP_SUB,
292 MARKUP_SUMMARY,
@@ -259,16 +295,18 @@
295 MARKUP_TBODY,
296 MARKUP_TD,
297 MARKUP_TFOOT,
298 MARKUP_TH,
299 MARKUP_THEAD,
300 MARKUP_TIME,
301 MARKUP_TITLE,
302 MARKUP_TR,
303 MARKUP_TT,
304 MARKUP_U,
305 MARKUP_UL,
306 MARKUP_VAR,
307 MARKUP_WBR,
308 MARKUP_VERBATIM
309 };
310
311 /*
312 ** The various markup is divided into the following types:
@@ -299,11 +337,11 @@
337
338 static const struct AllowedMarkup {
339 const char *zName; /* Name of the markup */
340 char iCode; /* The MARKUP_* code */
341 short int iType; /* The MUTYPE_* code */
342 amsk_t allowedAttr; /* Allowed attributes on this markup */
343 } aMarkup[] = {
344 { 0, MARKUP_INVALID, 0, 0 },
345 { "a", MARKUP_A, MUTYPE_HYPERLINK,
346 AMSK_HREF|AMSK_NAME|AMSK_CLASS|AMSK_TARGET|AMSK_STYLE|
347 AMSK_TITLE},
@@ -316,10 +354,12 @@
354 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
355 { "b", MARKUP_B, MUTYPE_FONT, AMSK_STYLE },
356 { "big", MARKUP_BIG, MUTYPE_FONT, AMSK_STYLE },
357 { "blockquote", MARKUP_BLOCKQUOTE, MUTYPE_BLOCK, AMSK_STYLE },
358 { "br", MARKUP_BR, MUTYPE_SINGLE, AMSK_CLEAR },
359 { "caption", MARKUP_CAPTION, MUTYPE_BLOCK,
360 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
361 { "center", MARKUP_CENTER, MUTYPE_BLOCK, AMSK_STYLE },
362 { "cite", MARKUP_CITE, MUTYPE_FONT, AMSK_STYLE },
363 { "code", MARKUP_CODE, MUTYPE_FONT, AMSK_STYLE },
364 { "col", MARKUP_COL, MUTYPE_SINGLE,
365 AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE },
@@ -326,18 +366,22 @@
366 { "colgroup", MARKUP_COLGROUP, MUTYPE_BLOCK,
367 AMSK_ALIGN|AMSK_CLASS|AMSK_COLSPAN|AMSK_WIDTH|AMSK_STYLE},
368 { "dd", MARKUP_DD, MUTYPE_LI, AMSK_STYLE },
369 { "del", MARKUP_DEL, MUTYPE_FONT, AMSK_STYLE },
370 { "details", MARKUP_DETAILS, MUTYPE_BLOCK,
371 AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_OPEN },
372 { "dfn", MARKUP_DFN, MUTYPE_FONT, AMSK_STYLE },
373 { "div", MARKUP_DIV, MUTYPE_BLOCK,
374 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
375 { "dl", MARKUP_DL, MUTYPE_LIST,
376 AMSK_COMPACT|AMSK_STYLE },
377 { "dt", MARKUP_DT, MUTYPE_LI, AMSK_STYLE },
378 { "em", MARKUP_EM, MUTYPE_FONT, AMSK_STYLE },
379 { "figcaption", MARKUP_FIGCAPTION, MUTYPE_BLOCK,
380 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
381 { "figure", MARKUP_FIGURE, MUTYPE_BLOCK,
382 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
383 { "font", MARKUP_FONT, MUTYPE_FONT,
384 AMSK_COLOR|AMSK_FACE|AMSK_SIZE|AMSK_STYLE },
385 { "footer", MARKUP_HTML5_FOOTER, MUTYPE_BLOCK,
386 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
387 { "h1", MARKUP_H1, MUTYPE_BLOCK,
@@ -358,29 +402,43 @@
402 AMSK_ALIGN|AMSK_COLOR|AMSK_SIZE|AMSK_WIDTH|
403 AMSK_STYLE|AMSK_CLASS },
404 { "i", MARKUP_I, MUTYPE_FONT, AMSK_STYLE },
405 { "img", MARKUP_IMG, MUTYPE_SINGLE,
406 AMSK_ALIGN|AMSK_ALT|AMSK_BORDER|AMSK_HEIGHT|
407 AMSK_HSPACE|AMSK_SRC|AMSK_VSPACE|AMSK_WIDTH|AMSK_STYLE },
408 { "ins", MARKUP_INS, MUTYPE_FONT, AMSK_STYLE },
409 { "kbd", MARKUP_KBD, MUTYPE_FONT, AMSK_STYLE },
410 { "label", MARKUP_LABEL, MUTYPE_FONT,
411 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
412 { "li", MARKUP_LI, MUTYPE_LI,
413 AMSK_TYPE|AMSK_VALUE|AMSK_STYLE },
414 { "mark", MARKUP_MARK, MUTYPE_FONT,
415 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
416 { "meter", MARKUP_METER, MUTYPE_FONT,
417 AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_VALUE },
418 { "nav", MARKUP_HTML5_NAV, MUTYPE_BLOCK,
419 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
420 { "nobr", MARKUP_NOBR, MUTYPE_FONT, 0 },
421 { "nowiki", MARKUP_NOWIKI, MUTYPE_SPECIAL, 0 },
422 { "ol", MARKUP_OL, MUTYPE_LIST,
423 AMSK_START|AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
424 { "p", MARKUP_P, MUTYPE_BLOCK,
425 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
426 { "picture", MARKUP_PICTURE, MUTYPE_BLOCK,
427 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
428 { "pre", MARKUP_PRE, MUTYPE_BLOCK, AMSK_STYLE },
429 { "progress", MARKUP_PROGRESS, MUTYPE_FONT,
430 AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_VALUE },
431 { "q", MARKUP_Q, MUTYPE_FONT,
432 AMSK_CLASS|AMSK_STYLE|AMSK_CITE },
433 { "s", MARKUP_S, MUTYPE_FONT, AMSK_STYLE },
434 { "samp", MARKUP_SAMP, MUTYPE_FONT, AMSK_STYLE },
435 { "section", MARKUP_HTML5_SECTION, MUTYPE_BLOCK,
436 AMSK_ID|AMSK_CLASS|AMSK_STYLE },
437 { "small", MARKUP_SMALL, MUTYPE_FONT, AMSK_STYLE },
438 { "source", MARKUP_SOURCE, MUTYPE_SINGLE,
439 AMSK_TYPE|AMSK_ID|AMSK_CLASS|AMSK_STYLE },
440 { "span", MARKUP_SPAN, MUTYPE_BLOCK,
441 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
442 { "strike", MARKUP_STRIKE, MUTYPE_FONT, AMSK_STYLE },
443 { "strong", MARKUP_STRONG, MUTYPE_FONT, AMSK_STYLE },
444 { "sub", MARKUP_SUB, MUTYPE_FONT, AMSK_STYLE },
@@ -401,18 +459,21 @@
459 { "th", MARKUP_TH, MUTYPE_TD,
460 AMSK_ALIGN|AMSK_BGCOLOR|AMSK_COLSPAN|
461 AMSK_ROWSPAN|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
462 { "thead", MARKUP_THEAD, MUTYPE_BLOCK,
463 AMSK_ALIGN|AMSK_CLASS|AMSK_STYLE },
464 { "time", MARKUP_TIME, MUTYPE_FONT,
465 AMSK_DATETIME|AMSK_ID|AMSK_CLASS|AMSK_STYLE|AMSK_TITLE },
466 { "title", MARKUP_TITLE, MUTYPE_BLOCK, 0 },
467 { "tr", MARKUP_TR, MUTYPE_TR,
468 AMSK_ALIGN|AMSK_BGCOLOR|AMSK_VALIGN|AMSK_CLASS|AMSK_STYLE },
469 { "tt", MARKUP_TT, MUTYPE_FONT, AMSK_STYLE },
470 { "u", MARKUP_U, MUTYPE_FONT, AMSK_STYLE },
471 { "ul", MARKUP_UL, MUTYPE_LIST,
472 AMSK_TYPE|AMSK_COMPACT|AMSK_STYLE },
473 { "var", MARKUP_VAR, MUTYPE_FONT, AMSK_STYLE },
474 { "wbr", MARKUP_WBR, MUTYPE_SINGLE, 0 },
475 { "verbatim", MARKUP_VERBATIM, MUTYPE_SPECIAL,
476 AMSK_ID|AMSK_TYPE },
477 };
478
479 void show_allowed_wiki_markup( void ){
@@ -816,23 +877,34 @@
877 unsigned char iACode; /* ATTR_* */
878 char *zValue; /* Argument to this attribute. Might be NULL */
879 char cTerm; /* Original argument termination character */
880 } aAttr[10];
881 };
882
883 /*
884 ** Return true if attribute iACode has already been parsed into p.
885 */
886 static int markupHasAttr(const ParsedMarkup *p, int iACode){
887 int i;
888 for(i=0; i<p->nAttr; i++){
889 if( p->aAttr[i].iACode==iACode ) return 1;
890 }
891 return 0;
892 }
893
894 /*
895 ** z[] is an HTML markup element - something that begins with '<'.
896 ** Parse this element into the p structure.
897 **
898 ** The content of z[] might be modified by converting characters
899 ** to lowercase and by inserting some "\000" characters.
900 */
901 static amsk_t parseMarkup(ParsedMarkup *p, char *z){
902 int i, j, c;
903 int iACode;
904 char *zValue;
905 amsk_t seen = 0;
906 char zTag[100];
907
908 if( z[1]=='/' ){
909 p->endTag = 1;
910 i = 2;
@@ -868,11 +940,11 @@
940 if( j<(int)sizeof(zTag)-1 ) zTag[j++] = fossil_tolower(z[i]);
941 i++;
942 }
943 zTag[j] = 0;
944 p->aAttr[p->nAttr].iACode = iACode = findAttr(zTag);
945 attrOk = iACode!=0 && !markupHasAttr(p, iACode);
946 while( fossil_isspace(z[i]) ){ z++; }
947 if( z[i]!='=' ){
948 p->aAttr[p->nAttr].zValue = 0;
949 p->aAttr[p->nAttr].cTerm = 0;
950 c = 0;
@@ -904,11 +976,11 @@
976 }
977 }
978 i++;
979 }
980 if( attrOk ){
981 if( aAttribute[iACode].iMask ) seen |= aAttribute[iACode].iMask;
982 p->nAttr++;
983 }
984 while( fossil_isspace(z[i]) ){ i++; }
985 if( z[i]==0 || z[i]=='>' || (z[i]=='/' && z[i+1]=='>') ) break;
986 }
@@ -1712,17 +1784,17 @@
1784 break;
1785 }
1786 case TOKEN_MARKUP: {
1787 const char *zId;
1788 int iDiv;
1789 (void)parseMarkup(&markup, z);
1790
1791 /* Convert <title> to <h1 align='center'> */
1792 if( markup.iCode==MARKUP_TITLE && !p->inVerbatim ){
1793 markup.iCode = MARKUP_H1;
1794 markup.nAttr = 1;
1795 markup.aAttr[0].iACode = ATTR_ALIGN;
1796 markup.aAttr[0].zValue = "center";
1797 markup.aAttr[0].cTerm = 0;
1798 }
1799
1800 /* Markup of the form </div id=ID> where there is a matching
@@ -1801,11 +1873,11 @@
1873 popStackToTag(p, markup.iCode);
1874 }else
1875
1876 /* Push <div> markup onto the stack together with the id=ID attribute.
1877 */
1878 if( markup.iCode==MARKUP_DIV && attributeValue(&markup, ATTR_ID)!=0 ){
1879 pushStackWithId(p, markup.iCode, markupId(&markup),
1880 (p->state & ALLOW_WIKI)!=0);
1881 }else
1882
1883 /* Enter <verbatim> processing. With verbatim enabled, all other
1884
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611611
$(SRCDIR)/copybtn.js \
612612
$(SRCDIR)/default.css \
613613
$(SRCDIR)/diff.js \
614614
$(SRCDIR)/diff.tcl \
615615
$(SRCDIR)/forum.js \
616
+ $(SRCDIR)/fossil.attach.js \
616617
$(SRCDIR)/fossil.bootstrap.js \
617618
$(SRCDIR)/fossil.confirmer.js \
618619
$(SRCDIR)/fossil.copybutton.js \
619620
$(SRCDIR)/fossil.diff.js \
620621
$(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
661662
$(SRCDIR)/sounds/e.wav \
662663
$(SRCDIR)/sounds/f.wav \
663664
$(SRCDIR)/style.admin_log.css \
664665
$(SRCDIR)/style.chat.css \
665666
$(SRCDIR)/style.fileedit.css \
667
+ $(SRCDIR)/style.forum.css \
666668
$(SRCDIR)/style.pikchrshow.css \
667669
$(SRCDIR)/style.uvlist.css \
668670
$(SRCDIR)/style.wikiedit.css \
669671
$(SRCDIR)/tree.js \
670672
$(SRCDIR)/useredit.js \
671673
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611 $(SRCDIR)/copybtn.js \
612 $(SRCDIR)/default.css \
613 $(SRCDIR)/diff.js \
614 $(SRCDIR)/diff.tcl \
615 $(SRCDIR)/forum.js \
 
616 $(SRCDIR)/fossil.bootstrap.js \
617 $(SRCDIR)/fossil.confirmer.js \
618 $(SRCDIR)/fossil.copybutton.js \
619 $(SRCDIR)/fossil.diff.js \
620 $(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
661 $(SRCDIR)/sounds/e.wav \
662 $(SRCDIR)/sounds/f.wav \
663 $(SRCDIR)/style.admin_log.css \
664 $(SRCDIR)/style.chat.css \
665 $(SRCDIR)/style.fileedit.css \
 
666 $(SRCDIR)/style.pikchrshow.css \
667 $(SRCDIR)/style.uvlist.css \
668 $(SRCDIR)/style.wikiedit.css \
669 $(SRCDIR)/tree.js \
670 $(SRCDIR)/useredit.js \
671
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611 $(SRCDIR)/copybtn.js \
612 $(SRCDIR)/default.css \
613 $(SRCDIR)/diff.js \
614 $(SRCDIR)/diff.tcl \
615 $(SRCDIR)/forum.js \
616 $(SRCDIR)/fossil.attach.js \
617 $(SRCDIR)/fossil.bootstrap.js \
618 $(SRCDIR)/fossil.confirmer.js \
619 $(SRCDIR)/fossil.copybutton.js \
620 $(SRCDIR)/fossil.diff.js \
621 $(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
662 $(SRCDIR)/sounds/e.wav \
663 $(SRCDIR)/sounds/f.wav \
664 $(SRCDIR)/style.admin_log.css \
665 $(SRCDIR)/style.chat.css \
666 $(SRCDIR)/style.fileedit.css \
667 $(SRCDIR)/style.forum.css \
668 $(SRCDIR)/style.pikchrshow.css \
669 $(SRCDIR)/style.uvlist.css \
670 $(SRCDIR)/style.wikiedit.css \
671 $(SRCDIR)/tree.js \
672 $(SRCDIR)/useredit.js \
673
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576576
"$(SRCDIR)\copybtn.js" \
577577
"$(SRCDIR)\default.css" \
578578
"$(SRCDIR)\diff.js" \
579579
"$(SRCDIR)\diff.tcl" \
580580
"$(SRCDIR)\forum.js" \
581
+ "$(SRCDIR)\fossil.attach.js" \
581582
"$(SRCDIR)\fossil.bootstrap.js" \
582583
"$(SRCDIR)\fossil.confirmer.js" \
583584
"$(SRCDIR)\fossil.copybutton.js" \
584585
"$(SRCDIR)\fossil.diff.js" \
585586
"$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
626627
"$(SRCDIR)\sounds\e.wav" \
627628
"$(SRCDIR)\sounds\f.wav" \
628629
"$(SRCDIR)\style.admin_log.css" \
629630
"$(SRCDIR)\style.chat.css" \
630631
"$(SRCDIR)\style.fileedit.css" \
632
+ "$(SRCDIR)\style.forum.css" \
631633
"$(SRCDIR)\style.pikchrshow.css" \
632634
"$(SRCDIR)\style.uvlist.css" \
633635
"$(SRCDIR)\style.wikiedit.css" \
634636
"$(SRCDIR)\tree.js" \
635637
"$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
12141216
echo "$(SRCDIR)\copybtn.js" >> $@
12151217
echo "$(SRCDIR)\default.css" >> $@
12161218
echo "$(SRCDIR)\diff.js" >> $@
12171219
echo "$(SRCDIR)\diff.tcl" >> $@
12181220
echo "$(SRCDIR)\forum.js" >> $@
1221
+ echo "$(SRCDIR)\fossil.attach.js" >> $@
12191222
echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
12201223
echo "$(SRCDIR)\fossil.confirmer.js" >> $@
12211224
echo "$(SRCDIR)\fossil.copybutton.js" >> $@
12221225
echo "$(SRCDIR)\fossil.diff.js" >> $@
12231226
echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
12641267
echo "$(SRCDIR)\sounds/e.wav" >> $@
12651268
echo "$(SRCDIR)\sounds/f.wav" >> $@
12661269
echo "$(SRCDIR)\style.admin_log.css" >> $@
12671270
echo "$(SRCDIR)\style.chat.css" >> $@
12681271
echo "$(SRCDIR)\style.fileedit.css" >> $@
1272
+ echo "$(SRCDIR)\style.forum.css" >> $@
12691273
echo "$(SRCDIR)\style.pikchrshow.css" >> $@
12701274
echo "$(SRCDIR)\style.uvlist.css" >> $@
12711275
echo "$(SRCDIR)\style.wikiedit.css" >> $@
12721276
echo "$(SRCDIR)\tree.js" >> $@
12731277
echo "$(SRCDIR)\useredit.js" >> $@
12741278
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576 "$(SRCDIR)\copybtn.js" \
577 "$(SRCDIR)\default.css" \
578 "$(SRCDIR)\diff.js" \
579 "$(SRCDIR)\diff.tcl" \
580 "$(SRCDIR)\forum.js" \
 
581 "$(SRCDIR)\fossil.bootstrap.js" \
582 "$(SRCDIR)\fossil.confirmer.js" \
583 "$(SRCDIR)\fossil.copybutton.js" \
584 "$(SRCDIR)\fossil.diff.js" \
585 "$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
626 "$(SRCDIR)\sounds\e.wav" \
627 "$(SRCDIR)\sounds\f.wav" \
628 "$(SRCDIR)\style.admin_log.css" \
629 "$(SRCDIR)\style.chat.css" \
630 "$(SRCDIR)\style.fileedit.css" \
 
631 "$(SRCDIR)\style.pikchrshow.css" \
632 "$(SRCDIR)\style.uvlist.css" \
633 "$(SRCDIR)\style.wikiedit.css" \
634 "$(SRCDIR)\tree.js" \
635 "$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
1214 echo "$(SRCDIR)\copybtn.js" >> $@
1215 echo "$(SRCDIR)\default.css" >> $@
1216 echo "$(SRCDIR)\diff.js" >> $@
1217 echo "$(SRCDIR)\diff.tcl" >> $@
1218 echo "$(SRCDIR)\forum.js" >> $@
 
1219 echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
1220 echo "$(SRCDIR)\fossil.confirmer.js" >> $@
1221 echo "$(SRCDIR)\fossil.copybutton.js" >> $@
1222 echo "$(SRCDIR)\fossil.diff.js" >> $@
1223 echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
1264 echo "$(SRCDIR)\sounds/e.wav" >> $@
1265 echo "$(SRCDIR)\sounds/f.wav" >> $@
1266 echo "$(SRCDIR)\style.admin_log.css" >> $@
1267 echo "$(SRCDIR)\style.chat.css" >> $@
1268 echo "$(SRCDIR)\style.fileedit.css" >> $@
 
1269 echo "$(SRCDIR)\style.pikchrshow.css" >> $@
1270 echo "$(SRCDIR)\style.uvlist.css" >> $@
1271 echo "$(SRCDIR)\style.wikiedit.css" >> $@
1272 echo "$(SRCDIR)\tree.js" >> $@
1273 echo "$(SRCDIR)\useredit.js" >> $@
1274
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576 "$(SRCDIR)\copybtn.js" \
577 "$(SRCDIR)\default.css" \
578 "$(SRCDIR)\diff.js" \
579 "$(SRCDIR)\diff.tcl" \
580 "$(SRCDIR)\forum.js" \
581 "$(SRCDIR)\fossil.attach.js" \
582 "$(SRCDIR)\fossil.bootstrap.js" \
583 "$(SRCDIR)\fossil.confirmer.js" \
584 "$(SRCDIR)\fossil.copybutton.js" \
585 "$(SRCDIR)\fossil.diff.js" \
586 "$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
627 "$(SRCDIR)\sounds\e.wav" \
628 "$(SRCDIR)\sounds\f.wav" \
629 "$(SRCDIR)\style.admin_log.css" \
630 "$(SRCDIR)\style.chat.css" \
631 "$(SRCDIR)\style.fileedit.css" \
632 "$(SRCDIR)\style.forum.css" \
633 "$(SRCDIR)\style.pikchrshow.css" \
634 "$(SRCDIR)\style.uvlist.css" \
635 "$(SRCDIR)\style.wikiedit.css" \
636 "$(SRCDIR)\tree.js" \
637 "$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
1216 echo "$(SRCDIR)\copybtn.js" >> $@
1217 echo "$(SRCDIR)\default.css" >> $@
1218 echo "$(SRCDIR)\diff.js" >> $@
1219 echo "$(SRCDIR)\diff.tcl" >> $@
1220 echo "$(SRCDIR)\forum.js" >> $@
1221 echo "$(SRCDIR)\fossil.attach.js" >> $@
1222 echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
1223 echo "$(SRCDIR)\fossil.confirmer.js" >> $@
1224 echo "$(SRCDIR)\fossil.copybutton.js" >> $@
1225 echo "$(SRCDIR)\fossil.diff.js" >> $@
1226 echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
1267 echo "$(SRCDIR)\sounds/e.wav" >> $@
1268 echo "$(SRCDIR)\sounds/f.wav" >> $@
1269 echo "$(SRCDIR)\style.admin_log.css" >> $@
1270 echo "$(SRCDIR)\style.chat.css" >> $@
1271 echo "$(SRCDIR)\style.fileedit.css" >> $@
1272 echo "$(SRCDIR)\style.forum.css" >> $@
1273 echo "$(SRCDIR)\style.pikchrshow.css" >> $@
1274 echo "$(SRCDIR)\style.uvlist.css" >> $@
1275 echo "$(SRCDIR)\style.wikiedit.css" >> $@
1276 echo "$(SRCDIR)\tree.js" >> $@
1277 echo "$(SRCDIR)\useredit.js" >> $@
1278
--- www/changes.wiki
+++ www/changes.wiki
@@ -5,24 +5,27 @@
55
context of the check-in being edited.
66
<li> Honor the NO_COLOR environment variable in the
77
"[/help/system|fossil sys ls]" command.
88
<li> On the [/help/www/info|/info webpage] (and similar) put a
99
"copy" button before the date.
10
- <li> Add the ".m4a" and ".oft" mimetypes.
10
+ <li> Add the ".m4a" and ".otf" mimetypes.
1111
<li> When doing a "fossil update", if a file under management needs to
1212
overwrite an unmanaged file, display the name of the backup that
1313
is made of the unmanaged file, and use file_delete() to delete
1414
the unmanaged file, even if that unmanaged file is read-only.
1515
<li> Improve the default prompts used by the
1616
"[/help/sqlite3|fossil sql]" command.
1717
<li> The captcha now uses light-gray boxes as the background, instead of
1818
spaces, to work around width inconsistencies in some fonts.
19
+ <li> Forum post editing and replying was overhauled with a new UI. Clients
20
+ with JavaScript disabled will still see the older forms.
1921
<li> Forum posts may now have attachments if their poster has the new "B"
20
- capability.</li>
22
+ capability.
2123
<li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
2224
to limit the size of file attachments to wiki pages, tech notes,
2325
tickets, and forum posts.
26
+ <li> Addded several HTML5 tags deemed harmless to the MD/wiki allowlists.
2427
</ol>
2528
2629
<h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
2730
<li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
2831
<li> The default configuration now allows robots to download any tarball
2932
--- www/changes.wiki
+++ www/changes.wiki
@@ -5,24 +5,27 @@
5 context of the check-in being edited.
6 <li> Honor the NO_COLOR environment variable in the
7 "[/help/system|fossil sys ls]" command.
8 <li> On the [/help/www/info|/info webpage] (and similar) put a
9 "copy" button before the date.
10 <li> Add the ".m4a" and ".oft" mimetypes.
11 <li> When doing a "fossil update", if a file under management needs to
12 overwrite an unmanaged file, display the name of the backup that
13 is made of the unmanaged file, and use file_delete() to delete
14 the unmanaged file, even if that unmanaged file is read-only.
15 <li> Improve the default prompts used by the
16 "[/help/sqlite3|fossil sql]" command.
17 <li> The captcha now uses light-gray boxes as the background, instead of
18 spaces, to work around width inconsistencies in some fonts.
 
 
19 <li> Forum posts may now have attachments if their poster has the new "B"
20 capability.</li>
21 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
22 to limit the size of file attachments to wiki pages, tech notes,
23 tickets, and forum posts.
 
24 </ol>
25
26 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
27 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
28 <li> The default configuration now allows robots to download any tarball
29
--- www/changes.wiki
+++ www/changes.wiki
@@ -5,24 +5,27 @@
5 context of the check-in being edited.
6 <li> Honor the NO_COLOR environment variable in the
7 "[/help/system|fossil sys ls]" command.
8 <li> On the [/help/www/info|/info webpage] (and similar) put a
9 "copy" button before the date.
10 <li> Add the ".m4a" and ".otf" mimetypes.
11 <li> When doing a "fossil update", if a file under management needs to
12 overwrite an unmanaged file, display the name of the backup that
13 is made of the unmanaged file, and use file_delete() to delete
14 the unmanaged file, even if that unmanaged file is read-only.
15 <li> Improve the default prompts used by the
16 "[/help/sqlite3|fossil sql]" command.
17 <li> The captcha now uses light-gray boxes as the background, instead of
18 spaces, to work around width inconsistencies in some fonts.
19 <li> Forum post editing and replying was overhauled with a new UI. Clients
20 with JavaScript disabled will still see the older forms.
21 <li> Forum posts may now have attachments if their poster has the new "B"
22 capability.
23 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
24 to limit the size of file attachments to wiki pages, tech notes,
25 tickets, and forum posts.
26 <li> Addded several HTML5 tags deemed harmless to the MD/wiki allowlists.
27 </ol>
28
29 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
30 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
31 <li> The default configuration now allows robots to download any tarball
32
--- www/customskin.md
+++ www/customskin.md
@@ -342,16 +342,16 @@
342342
section for more information on the two possible variable formats.
343343
344344
For example, first few lines of a typical Skin Header will look
345345
like this:
346346
347
- <div class="header">
347
+ <header>
348348
<div class="title"><h1>$<project_name></h1>$<title>/div>
349349
350350
After variables are substituted by TH1, that will look more like this:
351351
352
- <div class="header">
352
+ <header>
353353
<div class="title"><h1>Project Name</h1>Page Title</div>
354354
355355
As you can see, two TH1 variable substitutions were done.
356356
357357
The same TH1 interpreter is used for both the header and the footer
358358
--- www/customskin.md
+++ www/customskin.md
@@ -342,16 +342,16 @@
342 section for more information on the two possible variable formats.
343
344 For example, first few lines of a typical Skin Header will look
345 like this:
346
347 <div class="header">
348 <div class="title"><h1>$<project_name></h1>$<title>/div>
349
350 After variables are substituted by TH1, that will look more like this:
351
352 <div class="header">
353 <div class="title"><h1>Project Name</h1>Page Title</div>
354
355 As you can see, two TH1 variable substitutions were done.
356
357 The same TH1 interpreter is used for both the header and the footer
358
--- www/customskin.md
+++ www/customskin.md
@@ -342,16 +342,16 @@
342 section for more information on the two possible variable formats.
343
344 For example, first few lines of a typical Skin Header will look
345 like this:
346
347 <header>
348 <div class="title"><h1>$<project_name></h1>$<title>/div>
349
350 After variables are substituted by TH1, that will look more like this:
351
352 <header>
353 <div class="title"><h1>Project Name</h1>Page Title</div>
354
355 As you can see, two TH1 variable substitutions were done.
356
357 The same TH1 interpreter is used for both the header and the footer
358
+96 -10
--- www/forum.wiki
+++ www/forum.wiki
@@ -408,11 +408,11 @@
408408
posts. This is intentional, as closing one's own post can be used to
409409
antagonize other forum users. For example, by posting something
410410
trollish or highly controversial in nature and closing the post to
411411
further responses.
412412
413
-<h2 name="status">Setting Post Statuses</h2>
413
+<h2 id="status">Setting Post Statuses</h2>
414414
415415
As of version 2.29 forum posts may be tagged with a status. The
416416
setting <tt>forum-statuses</tt> controls whether this feature is is
417417
enabled. If it is not set, is not valid JSON5, or has only a single
418418
entry, then status are not shown in the forum.
@@ -474,22 +474,23 @@
474474
rendering a newer version.
475475
476476
Caveat: a "closed" status is not recommended because it's easy to confuse with
477477
the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478478
differently and predates that "status" tag support by about three years. The
479
-"closed" semantics cannot be trivially consolidated with those of "status".
480
-
481
-<h2 name="attachments">Attachments</h2>
482
-
483
-As of version 2.29 users with the [./caps/index.md|'B' capability]
484
-may attach files to forum posts. Files may not be attached until a
485
-forum post is saved for the first time, after which an "Attach" button
486
-will appear in the post when it is selected. Attached files undergo
479
+"closed" semantics cannot be trivially consolidated with those of "status"
480
+but we reserve the right to do so at some point.
481
+
482
+<h2 id="attachments">Attachments</h2>
483
+
484
+As of version 2.29 users with the [./caps/index.md|'B' capability] may
485
+attach files to forum posts. Files may not be attached until a forum
486
+post is saved for the first time, after which an "Attach" button will
487
+appear in the post when it is selected. Attached files undergo
487488
moderation exactly like forum posts do. When a moderator accepts a
488489
pending-moderation posts, all files attached to it which are also
489490
pending approval are also approved. Similarly, when a moderator
490
-rejects a pending-moderation post, all files attached to it when are
491
+rejects a pending-moderation post, all files attached to it which are
491492
also pending approval are rejected.
492493
493494
Developer notes regarding the save-before-attach limitation:
494495
495496
* We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
513514
notified, admin reads post, user attaches a file at that time,
514515
admin approves post.
515516
516517
TBD is whether to accept that case or to remove automatic approval of
517518
attached files.
519
+
520
+<h2 id="drafts">Draft Edits</h2>
521
+
522
+The forum uses, if available, JavaScript's <code>localStorage</code>
523
+or <code>sessionStorage</code> to store local drafts of new
524
+posts, replies, and edits to existing posts. Each time an editor
525
+input field loses focus, the draft is saved. When viewing a forum
526
+thread, the Edit and Reply buttons get clearly marked if they
527
+have an associated local draft.
528
+
529
+Local drafts for replies and responses (but not new posts) are
530
+automatically purged at semi-random intervals. Every Nth visit to a
531
+forum page has a small chance to run the cleanup, and drafts older
532
+than 10 days old are purged.
533
+
534
+If neither <code>localStorage</code> nor <code>sessionStorage</code>
535
+are available, it uses a transient storage object which is recreated
536
+on each page-load, so it cannot store drafts outside of a single
537
+thread at a time.
538
+
539
+Beware: Each draft is keyed to the post it is editing or replied to,
540
+which means that editing or replying to the same post from different
541
+tabs will cause collisions. The editor to most recently lose the focus
542
+will save the draft, overwriting the other.
543
+
544
+In browsers which support Web Locks, attempts to edit resp. reply to a
545
+post which is already being edited resp. replied to in another tab
546
+will show an error for the second and subsequent tabs. The first tab
547
+to use the edit/reply buttons will hold a separate Web Lock for each
548
+and forbid edit/reply access to the post in other tabs until the
549
+locking tab is closed.
550
+
551
+
552
+<h2 id="padding">Curious Extra Page Padding</h2>
553
+
554
+Users may notice that the forum pages get a bunch of padding added to
555
+the bottom of the page when an editor widget is opened. This is not a
556
+bug, but a workaround to help avoid the UI jumping around when
557
+rendering a preview wildly resizes the widget. The effect is
558
+especially helpful when working on posts while the bottom footer of
559
+the page is in the viewport, as the footer is a hard boundary against
560
+scrolling. With the extra padding, the editor widget's top edge shifts
561
+around less when the preview is shown and hidden.
562
+
563
+<h2 id="fork">Forked Edits</h2>
564
+
565
+It is possible to "fork" forum messages, in the same way that any
566
+given check-in may cause a fork. This can happen when a message is
567
+edited concurrently from two or more browser tabs or via two or more
568
+fossil instances which subsequently sync with each other.
569
+
570
+When a forum post is forked, the UI currently behaves as it does for
571
+concurrent wiki page edits, showing only the most recent
572
+edit. Currently (2026-06) the UI is incapable of showing the older
573
+forked copies but (A) the data are still part of the SCM history and
574
+(B) there are plans to improve the UI to show such forks.
575
+
576
+For trivia's sake, here's a query which finds posts which have
577
+forks:
578
+
579
+<pre><verbatim>
580
+SELECT bp.rid AS parentRid, bp.uuid parentUuid, count(f.fprev) forkCount
581
+FROM blob b, blob bp, forumpost f
582
+WHERE b.rid=f.fpid
583
+AND f.fprev=bp.rid
584
+GROUP BY f.fprev
585
+HAVING forkCount>1;
586
+</verbatim></pre>
587
+
588
+And here's one which lists forked posts:
589
+
590
+<pre><verbatim>
591
+WITH multiparent(mpid) AS (
592
+ SELECT fprev FROM forumpost f
593
+ GROUP BY fprev
594
+ HAVING count(fprev)>1
595
+)
596
+SELECT datetime(fmtime), forumpost.*
597
+FROM forumpost, multiparent
598
+WHERE fprev=mpid
599
+ORDER BY mpid, fpid, fmtime;
600
+</verbatim></pre>
601
+
602
+The telling part is the <code>fprev</code> column: those with matching
603
+values forked from the post referred to by <code>fprev</code>.
518604
--- www/forum.wiki
+++ www/forum.wiki
@@ -408,11 +408,11 @@
408 posts. This is intentional, as closing one's own post can be used to
409 antagonize other forum users. For example, by posting something
410 trollish or highly controversial in nature and closing the post to
411 further responses.
412
413 <h2 name="status">Setting Post Statuses</h2>
414
415 As of version 2.29 forum posts may be tagged with a status. The
416 setting <tt>forum-statuses</tt> controls whether this feature is is
417 enabled. If it is not set, is not valid JSON5, or has only a single
418 entry, then status are not shown in the forum.
@@ -474,22 +474,23 @@
474 rendering a newer version.
475
476 Caveat: a "closed" status is not recommended because it's easy to confuse with
477 the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478 differently and predates that "status" tag support by about three years. The
479 "closed" semantics cannot be trivially consolidated with those of "status".
480
481 <h2 name="attachments">Attachments</h2>
482
483 As of version 2.29 users with the [./caps/index.md|'B' capability]
484 may attach files to forum posts. Files may not be attached until a
485 forum post is saved for the first time, after which an "Attach" button
486 will appear in the post when it is selected. Attached files undergo
 
487 moderation exactly like forum posts do. When a moderator accepts a
488 pending-moderation posts, all files attached to it which are also
489 pending approval are also approved. Similarly, when a moderator
490 rejects a pending-moderation post, all files attached to it when are
491 also pending approval are rejected.
492
493 Developer notes regarding the save-before-attach limitation:
494
495 * We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
513 notified, admin reads post, user attaches a file at that time,
514 admin approves post.
515
516 TBD is whether to accept that case or to remove automatic approval of
517 attached files.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
--- www/forum.wiki
+++ www/forum.wiki
@@ -408,11 +408,11 @@
408 posts. This is intentional, as closing one's own post can be used to
409 antagonize other forum users. For example, by posting something
410 trollish or highly controversial in nature and closing the post to
411 further responses.
412
413 <h2 id="status">Setting Post Statuses</h2>
414
415 As of version 2.29 forum posts may be tagged with a status. The
416 setting <tt>forum-statuses</tt> controls whether this feature is is
417 enabled. If it is not set, is not valid JSON5, or has only a single
418 entry, then status are not shown in the forum.
@@ -474,22 +474,23 @@
474 rendering a newer version.
475
476 Caveat: a "closed" status is not recommended because it's easy to confuse with
477 the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478 differently and predates that "status" tag support by about three years. The
479 "closed" semantics cannot be trivially consolidated with those of "status"
480 but we reserve the right to do so at some point.
481
482 <h2 id="attachments">Attachments</h2>
483
484 As of version 2.29 users with the [./caps/index.md|'B' capability] may
485 attach files to forum posts. Files may not be attached until a forum
486 post is saved for the first time, after which an "Attach" button will
487 appear in the post when it is selected. Attached files undergo
488 moderation exactly like forum posts do. When a moderator accepts a
489 pending-moderation posts, all files attached to it which are also
490 pending approval are also approved. Similarly, when a moderator
491 rejects a pending-moderation post, all files attached to it which are
492 also pending approval are rejected.
493
494 Developer notes regarding the save-before-attach limitation:
495
496 * We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
514 notified, admin reads post, user attaches a file at that time,
515 admin approves post.
516
517 TBD is whether to accept that case or to remove automatic approval of
518 attached files.
519
520 <h2 id="drafts">Draft Edits</h2>
521
522 The forum uses, if available, JavaScript's <code>localStorage</code>
523 or <code>sessionStorage</code> to store local drafts of new
524 posts, replies, and edits to existing posts. Each time an editor
525 input field loses focus, the draft is saved. When viewing a forum
526 thread, the Edit and Reply buttons get clearly marked if they
527 have an associated local draft.
528
529 Local drafts for replies and responses (but not new posts) are
530 automatically purged at semi-random intervals. Every Nth visit to a
531 forum page has a small chance to run the cleanup, and drafts older
532 than 10 days old are purged.
533
534 If neither <code>localStorage</code> nor <code>sessionStorage</code>
535 are available, it uses a transient storage object which is recreated
536 on each page-load, so it cannot store drafts outside of a single
537 thread at a time.
538
539 Beware: Each draft is keyed to the post it is editing or replied to,
540 which means that editing or replying to the same post from different
541 tabs will cause collisions. The editor to most recently lose the focus
542 will save the draft, overwriting the other.
543
544 In browsers which support Web Locks, attempts to edit resp. reply to a
545 post which is already being edited resp. replied to in another tab
546 will show an error for the second and subsequent tabs. The first tab
547 to use the edit/reply buttons will hold a separate Web Lock for each
548 and forbid edit/reply access to the post in other tabs until the
549 locking tab is closed.
550
551
552 <h2 id="padding">Curious Extra Page Padding</h2>
553
554 Users may notice that the forum pages get a bunch of padding added to
555 the bottom of the page when an editor widget is opened. This is not a
556 bug, but a workaround to help avoid the UI jumping around when
557 rendering a preview wildly resizes the widget. The effect is
558 especially helpful when working on posts while the bottom footer of
559 the page is in the viewport, as the footer is a hard boundary against
560 scrolling. With the extra padding, the editor widget's top edge shifts
561 around less when the preview is shown and hidden.
562
563 <h2 id="fork">Forked Edits</h2>
564
565 It is possible to "fork" forum messages, in the same way that any
566 given check-in may cause a fork. This can happen when a message is
567 edited concurrently from two or more browser tabs or via two or more
568 fossil instances which subsequently sync with each other.
569
570 When a forum post is forked, the UI currently behaves as it does for
571 concurrent wiki page edits, showing only the most recent
572 edit. Currently (2026-06) the UI is incapable of showing the older
573 forked copies but (A) the data are still part of the SCM history and
574 (B) there are plans to improve the UI to show such forks.
575
576 For trivia's sake, here's a query which finds posts which have
577 forks:
578
579 <pre><verbatim>
580 SELECT bp.rid AS parentRid, bp.uuid parentUuid, count(f.fprev) forkCount
581 FROM blob b, blob bp, forumpost f
582 WHERE b.rid=f.fpid
583 AND f.fprev=bp.rid
584 GROUP BY f.fprev
585 HAVING forkCount>1;
586 </verbatim></pre>
587
588 And here's one which lists forked posts:
589
590 <pre><verbatim>
591 WITH multiparent(mpid) AS (
592 SELECT fprev FROM forumpost f
593 GROUP BY fprev
594 HAVING count(fprev)>1
595 )
596 SELECT datetime(fmtime), forumpost.*
597 FROM forumpost, multiparent
598 WHERE fprev=mpid
599 ORDER BY mpid, fpid, fmtime;
600 </verbatim></pre>
601
602 The telling part is the <code>fprev</code> column: those with matching
603 values forked from the post referred to by <code>fprev</code>.
604
+142 -119
--- www/fossil-v-git.wiki
+++ www/fossil-v-git.wiki
@@ -20,12 +20,12 @@
2020
In this document, we set all of that similarity and interoperability
2121
aside and focus on the important differences between the two, especially
2222
those that impact the user experience.
2323
2424
Keep in mind that you are reading this on a Fossil website, and though
25
-we try to be fair, the information here
26
-might be biased in favor of Fossil, if only because we spend most of our
25
+we try to be fair, the information here will inevitably
26
+be biased in favor of Fossil purely because we spend most of our
2727
time using Fossil, not Git. Ask around for second opinions from
2828
people who have used <em>both</em> Fossil and Git.
2929
3030
If you want a more practical, less philosophical guide to moving from
3131
Git to Fossil, see our [./gitusers.md | Git to Fossil Translation Guide].
@@ -178,36 +178,40 @@
178178
This policy is particularly useful when running Fossil inside a
179179
restrictive container, anything from [./chroot.md | classic chroot
180180
jails] to modern [https://en.wikipedia.org/wiki/OS-level_virtualization
181181
| OS-level virtualization mechanisms] such as
182182
[https://en.wikipedia.org/wiki/Docker_(software) | Docker].
183
-Our [./containers.md | stock container image] is under 8&nbsp;MB when
184
-uncompressed and running. It contains nothing but a single
183
+Our [./containers.md | stock container image] is under 11&nbsp;MB when
184
+uncompressed and running because it contains nothing but a single
185185
statically-linked binary.
186186
187
-If you build a dynamically linked binary instead, Fossil's on-disk size
188
-drops to around 6&nbsp;MB, and it's dependent only on widespread
187
+If you build a dynamically linked binary instead, a Linux
188
+x86_64 build of Fossil drops to under 5&nbsp;MB on-disk, stripped.
189
+It will depend only on widespread
189190
platform libraries with stable ABIs such as glibc, zlib, and openssl.
190191
191
-Full static linking is easier on Windows, so our precompiled Windows
192
-binaries are just a ZIP archive
193
-containing only "<tt>fossil.exe</tt>". There is no "<tt>setup.exe</tt>"
194
-to run.
192
+Much the same is true on Windows, where our precompiled static binaries
193
+are distributed inside a ZIP archive containing "<tt>fossil.exe</tt>"
194
+and not a thing else, with a size on-par that of the Linux container
195
+build. There is no "<tt>setup.exe</tt>" to run; just copy it into your
196
+<tt>%PATH%</tt>.
195197
196198
Fossil is easy to build from sources. Just run
197199
"<tt>./configure && make</tt>" on POSIX systems and
198200
"<tt>nmake /f Makefile.msc</tt>" on Windows.
199201
200
-Contrast a basic installation of Git, which takes up about
201
-15&nbsp;MiB on Debian 10 across 230 files, not counting the contents of
202
-<tt>/usr/share/doc</tt> or <tt>/usr/share/locale</tt>. If you need to
203
-deploy to any platform where you cannot count on facilities like the POSIX
204
-shell, Perl interpreter, and Tcl/Tk platform needed to fully use Git
205
-as part of the base platform, the full footprint of a Git installation
206
-extends to more like 45&nbsp;MiB and thousands of files. This complicates
207
-several common scenarios: Git for Windows, chrooted Git servers,
208
-Docker images...
202
+Git, by contrast, takes 25&nbsp;MiB on Ubuntu 26.04 across 940 files.
203
+That doesn't count platform facilities like the POSIX shell and script
204
+interpreters its full feature set requires. A fairer comparison to
205
+Fossil's single static binary container is the Docker Hardened Image for
206
+[https://hub.docker.com/hardened-images/catalog/dhi/git |Git 2.x on
207
+Alpine], which presently weighs in at 36.3 megs unpacked and 607 files,
208
+if you count all the <tt>busybox</tt> symlinks which would otherwise be
209
+separate binaries on a more traditional Linux system. Worst of all are
210
+the "Git for Windows" packages where they end up needing to ship a large
211
+yet nerfed Linux-like userland to support Git's many loosely coupled
212
+pieces, approaching a hundred megs and thousands of files.
209213
210214
Some say that Git more closely adheres to the Unix philosophy,
211215
summarized as "many small tools, loosely joined," but we have many
212216
examples of other successful Unix software that violates that principle
213217
to good effect, from Apache to Python to ZFS. We can infer from that
@@ -216,26 +220,47 @@
216220
matters is effectiveness and efficiency. We believe Fossil achieves
217221
this.
218222
219223
The above size comparisons aren't apples-to-apples anyway. We've
220224
compared the size of Fossil with all of its [#features | many built-in
221
-features] to a fairly minimal Git installation. You must add a lot of
222
-third-party software to Git to give it a Fossil-equivalent feature set.
223
-Consider [https://about.gitlab.com/|GitLab], a third-party extension to
224
-Git wrapping it in many features, making it roughly Fossil-equivalent,
225
-though [https://docs.gitlab.com/ee/install/requirements.html|much more
226
-resource hungry] and hence more costly to run than the equivalent Fossil
227
-setup. [https://hub.docker.com/r/gitlab/gitlab-ce/ | The official GitLab
228
-Community Edition container] currently clocks in at 2.66 GiB!
229
-
230
-GitLab's requirements are easy to accept when you're dedicating
231
-a local rack server or blade to it, since its minimum requirements are
232
-more or less a description of the smallest
233
-thing you could call a "server" these days, but when you go to host that
234
-in the cloud, you can expect to pay about 8 times as much to comfortably host
235
-GitLab as for Fossil.³ This difference is largely due to basic
236
-technology choices: Ruby and PostgreSQL vs C and SQLite.
225
+features] to a relatively bare-bones Git installation. You must add a
226
+lot of third-party software to Git to give it a Fossil-equivalent
227
+feature set.
228
+
229
+Consider [https://about.gitlab.com/|GitLab], which wraps Git in enough
230
+features to bring it to a rough (very rough) parity with Fossil. While
231
+it is certainly ahead in some areas — automation comes to mind — it must
232
+be noted that GitLab's own forums are on Discourse, not self-hosted as
233
+with Fossil. You may then argue that Discourse is superior to the Fossil
234
+forum feature, but this merely brings us back to another of Fossil's
235
+advantages: when the posts are part of the repo, you can migrate your
236
+entire project to another host merely by standing that repo back up on
237
+other hardware. Choosing to assemble your project hosting from multiple
238
+pieces requires each to have their own backups, their own management
239
+processes, and their own migration strategies.
240
+
241
+Even then, these disparate services do not cooperate at the same level
242
+as in Fossil, where the ability to have a forum post linking to a wiki
243
+article linking to a trouble ticket linking to a commit falls out of the
244
+model nearly for free. These are all internal links, mind, potentially
245
+using nothing but repository artifact hashes, all backed by SQLite's
246
+referential integrity. With something like GitLab, its "internal
247
+references" are tacked on after the fact, not Git repo hashes at all,
248
+and when it comes to external services like Discourse, history tells us
249
+you're storing up tech debt which will come due at some future point in
250
+the form of piles of broken links when one piece or the other needs
251
+changing out.
252
+
253
+Furthermore, GitLab is far more resource hungry even in its
254
+[https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/ |
255
+minimal configuration], hence more costly to run than the equivalent
256
+Fossil setup. [https://hub.docker.com/r/gitlab/gitlab-ce/ | The
257
+official GitLab Community Edition container] currently clocks in at
258
+3.44 GiB, independent of add-ons like Discourse. You can expect it to
259
+cost around 8× as much to host it on a cloud service. Even pared down
260
+to the minimum, there remain the consequences from the difference in
261
+basic technology choices: Ruby and PostgreSQL vs C and SQLite.
237262
238263
The Fossil project itself is [./selfhost.wiki|hosted on a small and
239264
inexpensive VPS]. A bare-bones $5/month VPS or a
240265
spare Raspberry Pi is sufficient to run a full-up project
241266
site, complete with tickets, wiki, chat, and forum, in addition to
@@ -255,33 +280,34 @@
255280
<tt>.git</tt> folder or compressed into bespoke key/value
256281
[https://git-scm.com/book/en/v2/Git-Internals-Packfiles|pack-files],
257282
whereas Fossil stores its objects in a [https://www.sqlite.org/|SQLite]
258283
database file which provides ACID transactions and a high-level query
259284
language.
260
-This difference is more than an implementation detail. It has important
285
+
286
+This difference is more than an implementation detail; it has important
261287
practical consequences.
262288
263
-One notable consequence is that it is difficult to find the descendants
289
+One notable example is that it is difficult to find the descendants
264290
of check-ins in Git.
265291
One can easily locate the ancestors of a particular Git check-in
266
-by following the pointers embedded in the check-in object, but it is
267
-difficult to go the other direction and locate the descendants of a
268
-check-in. It is so difficult, in fact, that neither native Git nor
269
-GitHub provide this capability short of crawling the
270
-[https://www.git-scm.com/docs/git-log|commit log]. With Fossil,
271
-on the other hand, finding descendants is a simple SQL query.
272
-It is common in Fossil to ask to see
273
-[/timeline?df=release&y=ci|all check-ins since the last release].
274
-Git lets you see "what came before". Fossil makes it just as
275
-easy to also see "what came after".
292
+by following the pointers embedded in the check-in object, but
293
+going the other direction is difficult enough
294
+that neither native Git nor the big "forge" facilities
295
+like GitHub and GitLab provide this capability short of crawling the
296
+[https://www.git-scm.com/docs/git-log|commit log]. In Fossil,
297
+we can find descendants using a simple SQL query, which then allows
298
+us to see [/timeline?df=release&y=ci|all check-ins since the last release],
299
+as but one example.
300
+Git lets you see "what came before," but Fossil makes it just as
301
+easy to also see "what came after."
276302
277303
Leaf check-ins in Git that lack a "ref" become "detached," making them
278304
difficult to locate and subject to garbage collection. This
279305
[https://stackoverflow.com/q/3965676 | detached head
280306
state] problem has caused grief for
281
-[https://www.google.com/search?q=git+detached+head+state | many
282
-Git users]. With
307
+[https://www.google.com/search?q=git+detached+head+state |
308
+untold millions of Git users]. With
283309
Fossil, detached heads are simply impossible because we can always find
284310
our way back into the Merkle tree using one or more of the relations
285311
in the SQL database.
286312
287313
The SQL query capabilities of Fossil make it easier to track the
@@ -288,34 +314,27 @@
288314
changes for one particular file within a project. For example,
289315
you can easily find
290316
[/finfo/www/fossil-v-git.wiki|the complete edit history of this one document],
291317
or even
292318
[/finfo/www/fossil-v-git.wiki?ubg|the same history color-coded by committer],
293
-Both questions are simple SQL query in Fossil, with procedural code
319
+Both come down to simple SQL queries in Fossil, with procedural code
294320
only being used to format the result for display.
295321
The same result could be obtained from Git, but because the data is
296322
in a key/value store, much more procedural code has to be written to
297
-walk the data and compute the result. And since that is a lot more
298
-work, the question is seldom asked.
323
+walk the data and compute the result.
299324
300325
The ease of querying Fossil data using SQL means that status or
301326
history information about the project under management is easier
302
-to obtain. Being easier means that it is more likely to happen.
327
+to obtain, hence more likely to happen, giving its developers better
328
+situational awareness.
303329
Fossil reports tend to be more detailed and useful.
304330
Compare [/timeline?c=6df7a853ec16865b|this Fossil timeline]
305331
to
306332
[https://github.com/drhsqlite/fossil-mirror/commits/master?after=f720c106d297ca1f61bccb30c5c191b88a626d01+34 |
307
-its closest equivalent in GitHub]. Judge for yourself: which of those
333
+its closest equivalent in the GitHub mirror]. Judge for yourself: which of those
308334
reports is more useful to a developer trying to understand what happened?
309335
310
-The bottom line is that even though Fossil and Git are built around
311
-the same low-level data structure, the use of SQL
312
-to query this data makes the data more accessible in Fossil, resulting
313
-in more detailed information being available to the user. This
314
-improves situational awareness and makes working on the project
315
-easier.
316
-
317336
<h3 id="portable">2.4 Portable</h3>
318337
319338
Fossil is largely written in ISO C, almost purely conforming to the
320339
original 1989 standard. We make very little use of
321340
[https://en.wikipedia.org/wiki/C99|C99], and we do not knowingly make
@@ -326,11 +345,11 @@
326345
facilities Fossil needs to do its thing. (Network sockets, file locking,
327346
etc.) There are certainly well-known platforms Fossil hasn't been ported
328347
to yet, but that's most likely due to lack of interest rather than
329348
inherent difficulties in doing the port. We believe the most stringent
330349
limit on its portability is that it assumes at least a 32-bit CPU and
331
-several megs of flat-addressed memory.⁴ Fossil isn't quite as
350
+several megs of flat-addressed memory.³ Fossil isn't quite as
332351
[https://www.sqlite.org/custombuild.html|portable as SQLite], but it's
333352
close.
334353
335354
Over half of the C code in Fossil is actually an embedded copy of the
336355
current version of SQLite. Much of what is Fossil-specific after you set
@@ -344,20 +363,20 @@
344363
necessary]. The server-side
345364
UI scripting uses a custom minimal
346365
[https://en.wikipedia.org/wiki/Tcl|Tcl] dialect called
347366
[./th1.md|TH1], which is
348367
embedded into Fossil itself. Fossil's build system and test suite are
349
-largely based on Tcl.⁵ All of this is quite portable.
368
+largely based on Tcl.⁴ All of this is quite portable.
350369
351370
About half of Git's code is POSIX C, and about a third is POSIX shell
352371
code. This is largely why the so-called "Git for Windows" distributions
353372
(both [https://git-scm.com/download/win|first-party] and
354373
[https://gitforwindows.org/|third-party]) are actually an
355374
[https://www.msys2.org/wiki/Home/|MSYS POSIX portability environment] bundled
356375
with all of the Git stuff, because it would be too painful to port Git
357376
natively to Windows. Git is a foreign citizen on Windows, speaking to it
358
-only through a translator.⁶
377
+only through a translator.⁵
359378
360379
While Fossil does lean toward POSIX norms when given a choice — LF-only
361380
line endings are treated as first-class citizens over CR+LF, for example
362381
— the Windows build of Fossil is truly native.
363382
@@ -444,11 +463,11 @@
444463
[https://www.git-scm.com/docs/git-request-pull|pull requests] offer
445464
a low-friction path to accepting
446465
[https://www.jonobacon.com/2012/07/25/building-strong-community-structural-integrity/|drive-by
447466
contributions]. Fossil's closest equivalents are its unique
448467
[/help/bundle|bundle] and [/help/patch|patch] features, which require higher engagement
449
- than firing off a PR.⁷ This difference comes directly from the
468
+ than firing off a PR.⁶ This difference comes directly from the
450469
initial designed purpose for each tool: the SQLite project doesn't
451470
accept outside contributions from previously-unknown developers, but
452471
the Linux kernel does.
453472
454473
* <b>No rebasing:</b> When your local repo clone syncs changes
@@ -500,16 +519,16 @@
500519
that everyone — especially the project leader — can maintain a better
501520
mental picture of what is happening, leading to better situational
502521
awareness.
503522
504523
By contrast, "…[https://docs.github.com/en/get-started/quickstart/contributing-to-projects|forking is
505
-at the core of social coding at GitHub]". As of January 2022,
506
-[https://github.com/search?q=is:public|Github hosts 47 million distinct
524
+at the core of social coding at GitHub]". As of June 2026,
525
+[https://github.com/search?q=is:public|Github hosts 324 million distinct
507526
software projects], most of which were created by forking a
508527
previously-existing project. Since this is
509
-[https://evansdata.com/reports/viewRelease.php?reportID=9 | roughly
510
-twice the number of developers in the world], it beggars belief that
528
+[https://www.griddynamics.com/blog/number-software-developers-world |
529
+~11× the number of developers in the world], it beggars belief that
511530
most of these forks are still under active development. The vast bulk
512531
of these must be abandoned one-off efforts. This is part of the nature
513532
of bazaar style development.
514533
515534
You can think about this difference in terms of
@@ -533,18 +552,18 @@
533552
<h4 id="scale">2.5.2 Scale</h4>
534553
535554
The Linux kernel has a far bigger developer community than that of
536555
SQLite: there are thousands and thousands of contributors to Linux, most
537556
of whom do not know each other's names. These thousands are responsible
538
-for producing roughly 89× more code than is in SQLite. (10.7
539
-[https://en.wikipedia.org/wiki/Source_lines_of_code|MLOC] vs. 0.12 MLOC
540
-according to [https://dwheeler.com/sloccount/|SLOCCount].) The Linux
557
+for producing roughly 73× more code than is in SQLite. (32.0
558
+[https://en.wikipedia.org/wiki/Source_lines_of_code|MLOC] vs. 0.44 MLOC
559
+according to [https://github.com/boyter/scc | scc].) The Linux
541560
kernel and its development process were already uncommonly large back in
542561
2005 when Git was designed, specifically to support the consequences of
543562
having such a large set of developers working on such a large code base.
544563
545
-95% of the code in SQLite comes from just four programmers, and 64% of
564
+95% of the code in SQLite comes from just six programmers, and 62% of
546565
it is from the lead developer alone. The SQLite developers know each
547566
other well and interact daily. Fossil was designed for this development
548567
model.
549568
550569
When choosing your DVCS, we think you should ask yourself whether the
@@ -564,35 +583,33 @@
564583
565584
Both Fossil and Git store history as a directed acyclic graph (DAG)
566585
of changes, but Git tends to focus more on individual branches of
567586
the DAG, whereas Fossil puts more emphasis on the entire DAG.
568587
569
-For example, the default behavior in Git is to only synchronize
570
-a single branch, whereas with Fossil the only sync option is to
571
-sync the entire DAG. Git commands,
588
+While a common usage pattern in Git is to only synchronize
589
+a single branch — <tt>git pull upstream feature/branch</tt> — instead
590
+of all refs, Fossil does not give you a choice; it
591
+syncs the entire DAG or nothing. Git commands,
572592
GitHub, and GitLab tend to show only a single branch at
573593
a time, whereas Fossil usually shows all parallel branches at
574594
once. Git has commands like "rebase" that help keep all relevant
575595
changes on a single branch, whereas Fossil encourages a style of
576596
many concurrent branches constantly springing into existence,
577597
undergoing active development in parallel for a few days or weeks, then
578598
merging back into the main line and disappearing.
579599
580600
This difference in emphasis arises from the different purposes of
581
-the two systems. Git focuses on individual branches, because that
601
+the two systems. Git's focus on individual branches
582602
is exactly what you want for a highly-distributed bazaar-style project
583603
such as Linux. Linus Torvalds does not want to see every check-in
584604
by every contributor to Linux: such extreme visibility does not scale
585605
well. Contrast Fossil, which was written for the cathedral-style SQLite project
586606
and its handful of active committers. Seeing all
587607
changes on all branches all at once helps keep the whole team
588608
up-to-date with what everybody else is doing, resulting in a more
589609
tightly focused and cohesive implementation.
590610
591
-Parts of this section are [https://fossil-scm.org/forum/forumpost/5961e969fa|disputed]
592
-by [https://github.com/olorin37|Jakub A. G.].
593
-
594611
595612
<h3 id="checkouts">2.6 One vs. Many Check-outs per Repository</h3>
596613
597614
Because Git commingles the repository data with the initial checkout of
598615
that repository, the default mode of operation in Git is to stick to that
@@ -612,20 +629,28 @@
612629
standard advice is to use a switch-in-place workflow in Fossil when
613630
the disturbance from switching branches is small, and to use multiple
614631
checkouts when you have long-lived working branches that are different
615632
enough that switching in place is disruptive.
616633
617
-While you can [./gitusers.md#worktree | use Git in the Fossil style],
618
-Git's default tie between working directory and
619
-repository means the standard method for working with a Git repo is to
620
-have one working directory only. Most Git tutorials teach this style, so
621
-it is how most people learn to use Git. Because relatively few people
622
-use Git with multiple working directories per repository, there are
634
+While you can [./gitusers.md#worktree | use Git in the Fossil style] via
635
+its worktree feature, tutorials continue to teach the style of having
636
+one working directory only. Git can even fight you on this, as when
637
+working with a forked repository; it is best to have independent clones
638
+of the upstream and your fork, to allow separate "remote" lists and
639
+such. This can result in two working directories but each having a
640
+captive repo clone each, which isn't in the spirit of <tt>git
641
+worktree</tt> at all. Yet, it beats the alternative, which then
642
+highlights a gap in the model of diverging and re-converging forks.
643
+Ideally, Git would let you create one of these forks as a worktree while
644
+maintaining a strong separation between your fork and the upstream repo,
645
+but it ends up being too much hassle to bother with.
646
+
647
+There are
623648
[https://duckduckgo.com/?q=git+worktree+problem | several known
624
-problems] with that way of working, problems which don't happen in Fossil because of
625
-the clear [./ckout-workflows.md | separation] between a Fossil repository and
626
-each working directory.
649
+problems] with the single worktree style, ones which don't happen in
650
+Fossil because of the clear [./ckout-workflows.md | separation] between
651
+a Fossil repository and each working directory.
627652
628653
This distinction matters because switching branches inside a single working directory loses local context
629654
on each switch.
630655
631656
For instance, in any software project where the runnable program must be
@@ -658,13 +683,10 @@
658683
659684
Plus,
660685
<tt>cd</tt> is faster to type than <tt>git checkout</tt> or <tt>fossil
661686
update</tt>.
662687
663
-Parts of this section are [https://fossil-scm.org/forum/forumpost/5961e969fa|disputed]
664
-by [https://github.com/olorin37|Jakub A. G.].
665
-
666688
<h3 id="history">2.7 What you should have done vs. What you actually did</h3>
667689
668690
Git puts a lot of emphasis on maintaining
669691
a "clean" check-in history. Extraneous and experimental branches by
670692
individual developers often never make it into the main repository.
@@ -822,14 +844,14 @@
822844
concepts to keep track of in your mental model of Fossil's internal
823845
operation.
824846
825847
Fossil's implementation of the feature is also simpler to describe. The
826848
brief online help for <tt>[/help/merge | fossil merge]</tt> is
827
-currently 41 lines long, to which you want to add the 600 lines of
849
+currently 50 lines long, to which you want to add the ~800 lines of
828850
[./branching.wiki | the branching document]. The equivalent
829851
documentation in Git is the aggregation of the man pages for the above
830
-three commands, which is over 1000 lines, much of it mutually redundant.
852
+three commands, which is approaching 1400 lines as of this writing, much of it mutually redundant.
831853
(e.g. Git's <tt>--edit</tt> and <tt>--no-commit</tt> options get
832854
described three times, each time differently.) Fossil's
833855
documentation is not only more concise, it gives a nice split of brief
834856
online help and full online documentation.
835857
@@ -852,35 +874,42 @@
852874
This not
853875
only solves the SHAttered problem, it should prevent a reoccurrence of
854876
similar problems for the foreseeable future.
855877
856878
Meanwhile, the Git community took until August 2018 to publish
857
-[https://git-scm.com/docs/hash-function-transition/|their first plan]
858
-for solving the same problem by moving to SHA-256, a variant of the
859
-[https://en.wikipedia.org/wiki/SHA-2 | older SHA-2 algorithm]. As of
860
-this writing in February 2020, that plan hasn't been implemented, as far
861
-as this author is aware, but there is now
862
-[https://lwn.net/ml/git/[email protected]/
863
-| a competing SHA-256 based plan] which requires complete repository
864
-conversion from SHA-1 to SHA-256, breaking all public hashes in the
865
-repo. One way to characterize such a massive upheaval in Git terms is a
866
-whole-project rebase, which violates the
867
-[https://www.atlassian.com/git/tutorials/merging-vs-rebasing#the-golden-rule-of-rebasing|Golden Rule of Rebasing].
868
-
869
-Regardless of the eventual implementation details, we fully expect Git
870
-to move off SHA-1 eventually and for the changes to take years more to
871
-percolate through the community.
872
-
879
+[https://git-scm.com/docs/hash-function-transition/ | their plan] for
880
+solving the same problem by moving to SHA-256, a variant of the
881
+[https://en.wikipedia.org/wiki/SHA-2 | older SHA-2 algorithm]. That is
882
+now technically implemented in the sense that <tt>git init
883
+--object-format=sha256</tt> exists, but note well: this is not only an
884
+optional setting, Git forge support is mixed, most notably
885
+[https://github.com/GitoxideLabs/gitoxide/issues/281 | lacking in
886
+GitHub], plus also BitBucket and others. This is doubtless because of
887
+this warning in the latest ([https://git-scm.com/docs/git-init/2.54.0 |
888
+as of this writing]) <tt>git init</tt> docs:
889
+
890
+<blockquote>Note: At present, there is no interoperability between
891
+SHA-256 repositories and SHA-1 repositories.</blockquote>
892
+
893
+Although we are now in the <i>tenth year</i> of this situation, there
894
+remains hope that Git will manage to make the transition without taking
895
+the full decade: the
896
+[https://www.deployhq.com/blog/git-3-0-on-the-horizon-what-git-users-need-to-know-about-the-next-major-release
897
+| latest plan] is that Git 3.0 will finally <i>(finally!)</i> switch to
898
+SHA256 by default, forcing the issue. Given the track record, we are
899
+taking a "show me" stance on this claim.
900
+
901
+Always remember, attacks only get better, never worse.
873902
Almost three years after Fossil solved this problem, the
874903
[https://sha-mbles.github.io/ | SHAmbles attack] was published, further
875904
weakening the case for continuing to use SHA-1.
876905
877906
The practical impact of attacks like SHAttered and SHAmbles on the
878907
Git and Fossil Merkle trees isn't clear, but you want to have your repositories
879
-moved over to a stronger hash algorithm before someone figures out how
880
-to make use of the weaknesses in the old one. Fossil has had this covered
881
-for years now, so that the solution is now almost universally deployed.
908
+moved over to a stronger hash algorithm <i>before</i> someone figures out how
909
+to make use of the weaknesses in the old one. Fossil's solution is long
910
+since [https://repology.org/project/fossil/versions | universally deployed].
882911
883912
<hr/>
884913
885914
<h3>Asides and Digressions</h3>
886915
@@ -910,16 +939,10 @@
910939
lightweight web server,
911940
<tt>[https://sqlite.org/althttpd/|althttpd]</tt>,
912941
which is configured as a front end to Fossil running in CGI mode on
913942
these sites.
914943
915
- <li><p>That estimate is based on pricing at Digital Ocean in
916
- mid-2019: Fossil will run just fine on the smallest instance they
917
- offer, at US $5/month, but the closest match to GitLab's minimum
918
- requirements among Digital Ocean's offerings currently costs
919
- $40/month.
920
-
921944
<li><p>This means you can give up waiting for Fossil to be ported to
922945
the PDP-11, but we remain hopeful that someone may eventually port
923946
it to [https://en.wikipedia.org/wiki/Z/OS|z/OS].
924947
925948
<li><p>"Why is there all this Tcl in and around Fossil?" you may
926949
--- www/fossil-v-git.wiki
+++ www/fossil-v-git.wiki
@@ -20,12 +20,12 @@
20 In this document, we set all of that similarity and interoperability
21 aside and focus on the important differences between the two, especially
22 those that impact the user experience.
23
24 Keep in mind that you are reading this on a Fossil website, and though
25 we try to be fair, the information here
26 might be biased in favor of Fossil, if only because we spend most of our
27 time using Fossil, not Git. Ask around for second opinions from
28 people who have used <em>both</em> Fossil and Git.
29
30 If you want a more practical, less philosophical guide to moving from
31 Git to Fossil, see our [./gitusers.md | Git to Fossil Translation Guide].
@@ -178,36 +178,40 @@
178 This policy is particularly useful when running Fossil inside a
179 restrictive container, anything from [./chroot.md | classic chroot
180 jails] to modern [https://en.wikipedia.org/wiki/OS-level_virtualization
181 | OS-level virtualization mechanisms] such as
182 [https://en.wikipedia.org/wiki/Docker_(software) | Docker].
183 Our [./containers.md | stock container image] is under 8&nbsp;MB when
184 uncompressed and running. It contains nothing but a single
185 statically-linked binary.
186
187 If you build a dynamically linked binary instead, Fossil's on-disk size
188 drops to around 6&nbsp;MB, and it's dependent only on widespread
 
189 platform libraries with stable ABIs such as glibc, zlib, and openssl.
190
191 Full static linking is easier on Windows, so our precompiled Windows
192 binaries are just a ZIP archive
193 containing only "<tt>fossil.exe</tt>". There is no "<tt>setup.exe</tt>"
194 to run.
 
195
196 Fossil is easy to build from sources. Just run
197 "<tt>./configure && make</tt>" on POSIX systems and
198 "<tt>nmake /f Makefile.msc</tt>" on Windows.
199
200 Contrast a basic installation of Git, which takes up about
201 15&nbsp;MiB on Debian 10 across 230 files, not counting the contents of
202 <tt>/usr/share/doc</tt> or <tt>/usr/share/locale</tt>. If you need to
203 deploy to any platform where you cannot count on facilities like the POSIX
204 shell, Perl interpreter, and Tcl/Tk platform needed to fully use Git
205 as part of the base platform, the full footprint of a Git installation
206 extends to more like 45&nbsp;MiB and thousands of files. This complicates
207 several common scenarios: Git for Windows, chrooted Git servers,
208 Docker images...
 
 
209
210 Some say that Git more closely adheres to the Unix philosophy,
211 summarized as "many small tools, loosely joined," but we have many
212 examples of other successful Unix software that violates that principle
213 to good effect, from Apache to Python to ZFS. We can infer from that
@@ -216,26 +220,47 @@
216 matters is effectiveness and efficiency. We believe Fossil achieves
217 this.
218
219 The above size comparisons aren't apples-to-apples anyway. We've
220 compared the size of Fossil with all of its [#features | many built-in
221 features] to a fairly minimal Git installation. You must add a lot of
222 third-party software to Git to give it a Fossil-equivalent feature set.
223 Consider [https://about.gitlab.com/|GitLab], a third-party extension to
224 Git wrapping it in many features, making it roughly Fossil-equivalent,
225 though [https://docs.gitlab.com/ee/install/requirements.html|much more
226 resource hungry] and hence more costly to run than the equivalent Fossil
227 setup. [https://hub.docker.com/r/gitlab/gitlab-ce/ | The official GitLab
228 Community Edition container] currently clocks in at 2.66 GiB!
229
230 GitLab's requirements are easy to accept when you're dedicating
231 a local rack server or blade to it, since its minimum requirements are
232 more or less a description of the smallest
233 thing you could call a "server" these days, but when you go to host that
234 in the cloud, you can expect to pay about 8 times as much to comfortably host
235 GitLab as for Fossil.³ This difference is largely due to basic
236 technology choices: Ruby and PostgreSQL vs C and SQLite.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
238 The Fossil project itself is [./selfhost.wiki|hosted on a small and
239 inexpensive VPS]. A bare-bones $5/month VPS or a
240 spare Raspberry Pi is sufficient to run a full-up project
241 site, complete with tickets, wiki, chat, and forum, in addition to
@@ -255,33 +280,34 @@
255 <tt>.git</tt> folder or compressed into bespoke key/value
256 [https://git-scm.com/book/en/v2/Git-Internals-Packfiles|pack-files],
257 whereas Fossil stores its objects in a [https://www.sqlite.org/|SQLite]
258 database file which provides ACID transactions and a high-level query
259 language.
260 This difference is more than an implementation detail. It has important
 
261 practical consequences.
262
263 One notable consequence is that it is difficult to find the descendants
264 of check-ins in Git.
265 One can easily locate the ancestors of a particular Git check-in
266 by following the pointers embedded in the check-in object, but it is
267 difficult to go the other direction and locate the descendants of a
268 check-in. It is so difficult, in fact, that neither native Git nor
269 GitHub provide this capability short of crawling the
270 [https://www.git-scm.com/docs/git-log|commit log]. With Fossil,
271 on the other hand, finding descendants is a simple SQL query.
272 It is common in Fossil to ask to see
273 [/timeline?df=release&y=ci|all check-ins since the last release].
274 Git lets you see "what came before". Fossil makes it just as
275 easy to also see "what came after".
276
277 Leaf check-ins in Git that lack a "ref" become "detached," making them
278 difficult to locate and subject to garbage collection. This
279 [https://stackoverflow.com/q/3965676 | detached head
280 state] problem has caused grief for
281 [https://www.google.com/search?q=git+detached+head+state | many
282 Git users]. With
283 Fossil, detached heads are simply impossible because we can always find
284 our way back into the Merkle tree using one or more of the relations
285 in the SQL database.
286
287 The SQL query capabilities of Fossil make it easier to track the
@@ -288,34 +314,27 @@
288 changes for one particular file within a project. For example,
289 you can easily find
290 [/finfo/www/fossil-v-git.wiki|the complete edit history of this one document],
291 or even
292 [/finfo/www/fossil-v-git.wiki?ubg|the same history color-coded by committer],
293 Both questions are simple SQL query in Fossil, with procedural code
294 only being used to format the result for display.
295 The same result could be obtained from Git, but because the data is
296 in a key/value store, much more procedural code has to be written to
297 walk the data and compute the result. And since that is a lot more
298 work, the question is seldom asked.
299
300 The ease of querying Fossil data using SQL means that status or
301 history information about the project under management is easier
302 to obtain. Being easier means that it is more likely to happen.
 
303 Fossil reports tend to be more detailed and useful.
304 Compare [/timeline?c=6df7a853ec16865b|this Fossil timeline]
305 to
306 [https://github.com/drhsqlite/fossil-mirror/commits/master?after=f720c106d297ca1f61bccb30c5c191b88a626d01+34 |
307 its closest equivalent in GitHub]. Judge for yourself: which of those
308 reports is more useful to a developer trying to understand what happened?
309
310 The bottom line is that even though Fossil and Git are built around
311 the same low-level data structure, the use of SQL
312 to query this data makes the data more accessible in Fossil, resulting
313 in more detailed information being available to the user. This
314 improves situational awareness and makes working on the project
315 easier.
316
317 <h3 id="portable">2.4 Portable</h3>
318
319 Fossil is largely written in ISO C, almost purely conforming to the
320 original 1989 standard. We make very little use of
321 [https://en.wikipedia.org/wiki/C99|C99], and we do not knowingly make
@@ -326,11 +345,11 @@
326 facilities Fossil needs to do its thing. (Network sockets, file locking,
327 etc.) There are certainly well-known platforms Fossil hasn't been ported
328 to yet, but that's most likely due to lack of interest rather than
329 inherent difficulties in doing the port. We believe the most stringent
330 limit on its portability is that it assumes at least a 32-bit CPU and
331 several megs of flat-addressed memory.⁴ Fossil isn't quite as
332 [https://www.sqlite.org/custombuild.html|portable as SQLite], but it's
333 close.
334
335 Over half of the C code in Fossil is actually an embedded copy of the
336 current version of SQLite. Much of what is Fossil-specific after you set
@@ -344,20 +363,20 @@
344 necessary]. The server-side
345 UI scripting uses a custom minimal
346 [https://en.wikipedia.org/wiki/Tcl|Tcl] dialect called
347 [./th1.md|TH1], which is
348 embedded into Fossil itself. Fossil's build system and test suite are
349 largely based on Tcl.⁵ All of this is quite portable.
350
351 About half of Git's code is POSIX C, and about a third is POSIX shell
352 code. This is largely why the so-called "Git for Windows" distributions
353 (both [https://git-scm.com/download/win|first-party] and
354 [https://gitforwindows.org/|third-party]) are actually an
355 [https://www.msys2.org/wiki/Home/|MSYS POSIX portability environment] bundled
356 with all of the Git stuff, because it would be too painful to port Git
357 natively to Windows. Git is a foreign citizen on Windows, speaking to it
358 only through a translator.⁶
359
360 While Fossil does lean toward POSIX norms when given a choice — LF-only
361 line endings are treated as first-class citizens over CR+LF, for example
362 — the Windows build of Fossil is truly native.
363
@@ -444,11 +463,11 @@
444 [https://www.git-scm.com/docs/git-request-pull|pull requests] offer
445 a low-friction path to accepting
446 [https://www.jonobacon.com/2012/07/25/building-strong-community-structural-integrity/|drive-by
447 contributions]. Fossil's closest equivalents are its unique
448 [/help/bundle|bundle] and [/help/patch|patch] features, which require higher engagement
449 than firing off a PR.⁷ This difference comes directly from the
450 initial designed purpose for each tool: the SQLite project doesn't
451 accept outside contributions from previously-unknown developers, but
452 the Linux kernel does.
453
454 * <b>No rebasing:</b> When your local repo clone syncs changes
@@ -500,16 +519,16 @@
500 that everyone — especially the project leader — can maintain a better
501 mental picture of what is happening, leading to better situational
502 awareness.
503
504 By contrast, "…[https://docs.github.com/en/get-started/quickstart/contributing-to-projects|forking is
505 at the core of social coding at GitHub]". As of January 2022,
506 [https://github.com/search?q=is:public|Github hosts 47 million distinct
507 software projects], most of which were created by forking a
508 previously-existing project. Since this is
509 [https://evansdata.com/reports/viewRelease.php?reportID=9 | roughly
510 twice the number of developers in the world], it beggars belief that
511 most of these forks are still under active development. The vast bulk
512 of these must be abandoned one-off efforts. This is part of the nature
513 of bazaar style development.
514
515 You can think about this difference in terms of
@@ -533,18 +552,18 @@
533 <h4 id="scale">2.5.2 Scale</h4>
534
535 The Linux kernel has a far bigger developer community than that of
536 SQLite: there are thousands and thousands of contributors to Linux, most
537 of whom do not know each other's names. These thousands are responsible
538 for producing roughly 89× more code than is in SQLite. (10.7
539 [https://en.wikipedia.org/wiki/Source_lines_of_code|MLOC] vs. 0.12 MLOC
540 according to [https://dwheeler.com/sloccount/|SLOCCount].) The Linux
541 kernel and its development process were already uncommonly large back in
542 2005 when Git was designed, specifically to support the consequences of
543 having such a large set of developers working on such a large code base.
544
545 95% of the code in SQLite comes from just four programmers, and 64% of
546 it is from the lead developer alone. The SQLite developers know each
547 other well and interact daily. Fossil was designed for this development
548 model.
549
550 When choosing your DVCS, we think you should ask yourself whether the
@@ -564,35 +583,33 @@
564
565 Both Fossil and Git store history as a directed acyclic graph (DAG)
566 of changes, but Git tends to focus more on individual branches of
567 the DAG, whereas Fossil puts more emphasis on the entire DAG.
568
569 For example, the default behavior in Git is to only synchronize
570 a single branch, whereas with Fossil the only sync option is to
571 sync the entire DAG. Git commands,
 
572 GitHub, and GitLab tend to show only a single branch at
573 a time, whereas Fossil usually shows all parallel branches at
574 once. Git has commands like "rebase" that help keep all relevant
575 changes on a single branch, whereas Fossil encourages a style of
576 many concurrent branches constantly springing into existence,
577 undergoing active development in parallel for a few days or weeks, then
578 merging back into the main line and disappearing.
579
580 This difference in emphasis arises from the different purposes of
581 the two systems. Git focuses on individual branches, because that
582 is exactly what you want for a highly-distributed bazaar-style project
583 such as Linux. Linus Torvalds does not want to see every check-in
584 by every contributor to Linux: such extreme visibility does not scale
585 well. Contrast Fossil, which was written for the cathedral-style SQLite project
586 and its handful of active committers. Seeing all
587 changes on all branches all at once helps keep the whole team
588 up-to-date with what everybody else is doing, resulting in a more
589 tightly focused and cohesive implementation.
590
591 Parts of this section are [https://fossil-scm.org/forum/forumpost/5961e969fa|disputed]
592 by [https://github.com/olorin37|Jakub A. G.].
593
594
595 <h3 id="checkouts">2.6 One vs. Many Check-outs per Repository</h3>
596
597 Because Git commingles the repository data with the initial checkout of
598 that repository, the default mode of operation in Git is to stick to that
@@ -612,20 +629,28 @@
612 standard advice is to use a switch-in-place workflow in Fossil when
613 the disturbance from switching branches is small, and to use multiple
614 checkouts when you have long-lived working branches that are different
615 enough that switching in place is disruptive.
616
617 While you can [./gitusers.md#worktree | use Git in the Fossil style],
618 Git's default tie between working directory and
619 repository means the standard method for working with a Git repo is to
620 have one working directory only. Most Git tutorials teach this style, so
621 it is how most people learn to use Git. Because relatively few people
622 use Git with multiple working directories per repository, there are
 
 
 
 
 
 
 
 
623 [https://duckduckgo.com/?q=git+worktree+problem | several known
624 problems] with that way of working, problems which don't happen in Fossil because of
625 the clear [./ckout-workflows.md | separation] between a Fossil repository and
626 each working directory.
627
628 This distinction matters because switching branches inside a single working directory loses local context
629 on each switch.
630
631 For instance, in any software project where the runnable program must be
@@ -658,13 +683,10 @@
658
659 Plus,
660 <tt>cd</tt> is faster to type than <tt>git checkout</tt> or <tt>fossil
661 update</tt>.
662
663 Parts of this section are [https://fossil-scm.org/forum/forumpost/5961e969fa|disputed]
664 by [https://github.com/olorin37|Jakub A. G.].
665
666 <h3 id="history">2.7 What you should have done vs. What you actually did</h3>
667
668 Git puts a lot of emphasis on maintaining
669 a "clean" check-in history. Extraneous and experimental branches by
670 individual developers often never make it into the main repository.
@@ -822,14 +844,14 @@
822 concepts to keep track of in your mental model of Fossil's internal
823 operation.
824
825 Fossil's implementation of the feature is also simpler to describe. The
826 brief online help for <tt>[/help/merge | fossil merge]</tt> is
827 currently 41 lines long, to which you want to add the 600 lines of
828 [./branching.wiki | the branching document]. The equivalent
829 documentation in Git is the aggregation of the man pages for the above
830 three commands, which is over 1000 lines, much of it mutually redundant.
831 (e.g. Git's <tt>--edit</tt> and <tt>--no-commit</tt> options get
832 described three times, each time differently.) Fossil's
833 documentation is not only more concise, it gives a nice split of brief
834 online help and full online documentation.
835
@@ -852,35 +874,42 @@
852 This not
853 only solves the SHAttered problem, it should prevent a reoccurrence of
854 similar problems for the foreseeable future.
855
856 Meanwhile, the Git community took until August 2018 to publish
857 [https://git-scm.com/docs/hash-function-transition/|their first plan]
858 for solving the same problem by moving to SHA-256, a variant of the
859 [https://en.wikipedia.org/wiki/SHA-2 | older SHA-2 algorithm]. As of
860 this writing in February 2020, that plan hasn't been implemented, as far
861 as this author is aware, but there is now
862 [https://lwn.net/ml/git/[email protected]/
863 | a competing SHA-256 based plan] which requires complete repository
864 conversion from SHA-1 to SHA-256, breaking all public hashes in the
865 repo. One way to characterize such a massive upheaval in Git terms is a
866 whole-project rebase, which violates the
867 [https://www.atlassian.com/git/tutorials/merging-vs-rebasing#the-golden-rule-of-rebasing|Golden Rule of Rebasing].
868
869 Regardless of the eventual implementation details, we fully expect Git
870 to move off SHA-1 eventually and for the changes to take years more to
871 percolate through the community.
872
 
 
 
 
 
 
 
873 Almost three years after Fossil solved this problem, the
874 [https://sha-mbles.github.io/ | SHAmbles attack] was published, further
875 weakening the case for continuing to use SHA-1.
876
877 The practical impact of attacks like SHAttered and SHAmbles on the
878 Git and Fossil Merkle trees isn't clear, but you want to have your repositories
879 moved over to a stronger hash algorithm before someone figures out how
880 to make use of the weaknesses in the old one. Fossil has had this covered
881 for years now, so that the solution is now almost universally deployed.
882
883 <hr/>
884
885 <h3>Asides and Digressions</h3>
886
@@ -910,16 +939,10 @@
910 lightweight web server,
911 <tt>[https://sqlite.org/althttpd/|althttpd]</tt>,
912 which is configured as a front end to Fossil running in CGI mode on
913 these sites.
914
915 <li><p>That estimate is based on pricing at Digital Ocean in
916 mid-2019: Fossil will run just fine on the smallest instance they
917 offer, at US $5/month, but the closest match to GitLab's minimum
918 requirements among Digital Ocean's offerings currently costs
919 $40/month.
920
921 <li><p>This means you can give up waiting for Fossil to be ported to
922 the PDP-11, but we remain hopeful that someone may eventually port
923 it to [https://en.wikipedia.org/wiki/Z/OS|z/OS].
924
925 <li><p>"Why is there all this Tcl in and around Fossil?" you may
926
--- www/fossil-v-git.wiki
+++ www/fossil-v-git.wiki
@@ -20,12 +20,12 @@
20 In this document, we set all of that similarity and interoperability
21 aside and focus on the important differences between the two, especially
22 those that impact the user experience.
23
24 Keep in mind that you are reading this on a Fossil website, and though
25 we try to be fair, the information here will inevitably
26 be biased in favor of Fossil purely because we spend most of our
27 time using Fossil, not Git. Ask around for second opinions from
28 people who have used <em>both</em> Fossil and Git.
29
30 If you want a more practical, less philosophical guide to moving from
31 Git to Fossil, see our [./gitusers.md | Git to Fossil Translation Guide].
@@ -178,36 +178,40 @@
178 This policy is particularly useful when running Fossil inside a
179 restrictive container, anything from [./chroot.md | classic chroot
180 jails] to modern [https://en.wikipedia.org/wiki/OS-level_virtualization
181 | OS-level virtualization mechanisms] such as
182 [https://en.wikipedia.org/wiki/Docker_(software) | Docker].
183 Our [./containers.md | stock container image] is under 11&nbsp;MB when
184 uncompressed and running because it contains nothing but a single
185 statically-linked binary.
186
187 If you build a dynamically linked binary instead, a Linux
188 x86_64 build of Fossil drops to under 5&nbsp;MB on-disk, stripped.
189 It will depend only on widespread
190 platform libraries with stable ABIs such as glibc, zlib, and openssl.
191
192 Much the same is true on Windows, where our precompiled static binaries
193 are distributed inside a ZIP archive containing "<tt>fossil.exe</tt>"
194 and not a thing else, with a size on-par that of the Linux container
195 build. There is no "<tt>setup.exe</tt>" to run; just copy it into your
196 <tt>%PATH%</tt>.
197
198 Fossil is easy to build from sources. Just run
199 "<tt>./configure && make</tt>" on POSIX systems and
200 "<tt>nmake /f Makefile.msc</tt>" on Windows.
201
202 Git, by contrast, takes 25&nbsp;MiB on Ubuntu 26.04 across 940 files.
203 That doesn't count platform facilities like the POSIX shell and script
204 interpreters its full feature set requires. A fairer comparison to
205 Fossil's single static binary container is the Docker Hardened Image for
206 [https://hub.docker.com/hardened-images/catalog/dhi/git |Git 2.x on
207 Alpine], which presently weighs in at 36.3 megs unpacked and 607 files,
208 if you count all the <tt>busybox</tt> symlinks which would otherwise be
209 separate binaries on a more traditional Linux system. Worst of all are
210 the "Git for Windows" packages where they end up needing to ship a large
211 yet nerfed Linux-like userland to support Git's many loosely coupled
212 pieces, approaching a hundred megs and thousands of files.
213
214 Some say that Git more closely adheres to the Unix philosophy,
215 summarized as "many small tools, loosely joined," but we have many
216 examples of other successful Unix software that violates that principle
217 to good effect, from Apache to Python to ZFS. We can infer from that
@@ -216,26 +220,47 @@
220 matters is effectiveness and efficiency. We believe Fossil achieves
221 this.
222
223 The above size comparisons aren't apples-to-apples anyway. We've
224 compared the size of Fossil with all of its [#features | many built-in
225 features] to a relatively bare-bones Git installation. You must add a
226 lot of third-party software to Git to give it a Fossil-equivalent
227 feature set.
228
229 Consider [https://about.gitlab.com/|GitLab], which wraps Git in enough
230 features to bring it to a rough (very rough) parity with Fossil. While
231 it is certainly ahead in some areas — automation comes to mind — it must
232 be noted that GitLab's own forums are on Discourse, not self-hosted as
233 with Fossil. You may then argue that Discourse is superior to the Fossil
234 forum feature, but this merely brings us back to another of Fossil's
235 advantages: when the posts are part of the repo, you can migrate your
236 entire project to another host merely by standing that repo back up on
237 other hardware. Choosing to assemble your project hosting from multiple
238 pieces requires each to have their own backups, their own management
239 processes, and their own migration strategies.
240
241 Even then, these disparate services do not cooperate at the same level
242 as in Fossil, where the ability to have a forum post linking to a wiki
243 article linking to a trouble ticket linking to a commit falls out of the
244 model nearly for free. These are all internal links, mind, potentially
245 using nothing but repository artifact hashes, all backed by SQLite's
246 referential integrity. With something like GitLab, its "internal
247 references" are tacked on after the fact, not Git repo hashes at all,
248 and when it comes to external services like Discourse, history tells us
249 you're storing up tech debt which will come due at some future point in
250 the form of piles of broken links when one piece or the other needs
251 changing out.
252
253 Furthermore, GitLab is far more resource hungry even in its
254 [https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/ |
255 minimal configuration], hence more costly to run than the equivalent
256 Fossil setup. [https://hub.docker.com/r/gitlab/gitlab-ce/ | The
257 official GitLab Community Edition container] currently clocks in at
258 3.44 GiB, independent of add-ons like Discourse. You can expect it to
259 cost around 8× as much to host it on a cloud service. Even pared down
260 to the minimum, there remain the consequences from the difference in
261 basic technology choices: Ruby and PostgreSQL vs C and SQLite.
262
263 The Fossil project itself is [./selfhost.wiki|hosted on a small and
264 inexpensive VPS]. A bare-bones $5/month VPS or a
265 spare Raspberry Pi is sufficient to run a full-up project
266 site, complete with tickets, wiki, chat, and forum, in addition to
@@ -255,33 +280,34 @@
280 <tt>.git</tt> folder or compressed into bespoke key/value
281 [https://git-scm.com/book/en/v2/Git-Internals-Packfiles|pack-files],
282 whereas Fossil stores its objects in a [https://www.sqlite.org/|SQLite]
283 database file which provides ACID transactions and a high-level query
284 language.
285
286 This difference is more than an implementation detail; it has important
287 practical consequences.
288
289 One notable example is that it is difficult to find the descendants
290 of check-ins in Git.
291 One can easily locate the ancestors of a particular Git check-in
292 by following the pointers embedded in the check-in object, but
293 going the other direction is difficult enough
294 that neither native Git nor the big "forge" facilities
295 like GitHub and GitLab provide this capability short of crawling the
296 [https://www.git-scm.com/docs/git-log|commit log]. In Fossil,
297 we can find descendants using a simple SQL query, which then allows
298 us to see [/timeline?df=release&y=ci|all check-ins since the last release],
299 as but one example.
300 Git lets you see "what came before," but Fossil makes it just as
301 easy to also see "what came after."
302
303 Leaf check-ins in Git that lack a "ref" become "detached," making them
304 difficult to locate and subject to garbage collection. This
305 [https://stackoverflow.com/q/3965676 | detached head
306 state] problem has caused grief for
307 [https://www.google.com/search?q=git+detached+head+state |
308 untold millions of Git users]. With
309 Fossil, detached heads are simply impossible because we can always find
310 our way back into the Merkle tree using one or more of the relations
311 in the SQL database.
312
313 The SQL query capabilities of Fossil make it easier to track the
@@ -288,34 +314,27 @@
314 changes for one particular file within a project. For example,
315 you can easily find
316 [/finfo/www/fossil-v-git.wiki|the complete edit history of this one document],
317 or even
318 [/finfo/www/fossil-v-git.wiki?ubg|the same history color-coded by committer],
319 Both come down to simple SQL queries in Fossil, with procedural code
320 only being used to format the result for display.
321 The same result could be obtained from Git, but because the data is
322 in a key/value store, much more procedural code has to be written to
323 walk the data and compute the result.
 
324
325 The ease of querying Fossil data using SQL means that status or
326 history information about the project under management is easier
327 to obtain, hence more likely to happen, giving its developers better
328 situational awareness.
329 Fossil reports tend to be more detailed and useful.
330 Compare [/timeline?c=6df7a853ec16865b|this Fossil timeline]
331 to
332 [https://github.com/drhsqlite/fossil-mirror/commits/master?after=f720c106d297ca1f61bccb30c5c191b88a626d01+34 |
333 its closest equivalent in the GitHub mirror]. Judge for yourself: which of those
334 reports is more useful to a developer trying to understand what happened?
335
 
 
 
 
 
 
 
336 <h3 id="portable">2.4 Portable</h3>
337
338 Fossil is largely written in ISO C, almost purely conforming to the
339 original 1989 standard. We make very little use of
340 [https://en.wikipedia.org/wiki/C99|C99], and we do not knowingly make
@@ -326,11 +345,11 @@
345 facilities Fossil needs to do its thing. (Network sockets, file locking,
346 etc.) There are certainly well-known platforms Fossil hasn't been ported
347 to yet, but that's most likely due to lack of interest rather than
348 inherent difficulties in doing the port. We believe the most stringent
349 limit on its portability is that it assumes at least a 32-bit CPU and
350 several megs of flat-addressed memory.³ Fossil isn't quite as
351 [https://www.sqlite.org/custombuild.html|portable as SQLite], but it's
352 close.
353
354 Over half of the C code in Fossil is actually an embedded copy of the
355 current version of SQLite. Much of what is Fossil-specific after you set
@@ -344,20 +363,20 @@
363 necessary]. The server-side
364 UI scripting uses a custom minimal
365 [https://en.wikipedia.org/wiki/Tcl|Tcl] dialect called
366 [./th1.md|TH1], which is
367 embedded into Fossil itself. Fossil's build system and test suite are
368 largely based on Tcl.⁴ All of this is quite portable.
369
370 About half of Git's code is POSIX C, and about a third is POSIX shell
371 code. This is largely why the so-called "Git for Windows" distributions
372 (both [https://git-scm.com/download/win|first-party] and
373 [https://gitforwindows.org/|third-party]) are actually an
374 [https://www.msys2.org/wiki/Home/|MSYS POSIX portability environment] bundled
375 with all of the Git stuff, because it would be too painful to port Git
376 natively to Windows. Git is a foreign citizen on Windows, speaking to it
377 only through a translator.⁵
378
379 While Fossil does lean toward POSIX norms when given a choice — LF-only
380 line endings are treated as first-class citizens over CR+LF, for example
381 — the Windows build of Fossil is truly native.
382
@@ -444,11 +463,11 @@
463 [https://www.git-scm.com/docs/git-request-pull|pull requests] offer
464 a low-friction path to accepting
465 [https://www.jonobacon.com/2012/07/25/building-strong-community-structural-integrity/|drive-by
466 contributions]. Fossil's closest equivalents are its unique
467 [/help/bundle|bundle] and [/help/patch|patch] features, which require higher engagement
468 than firing off a PR.⁶ This difference comes directly from the
469 initial designed purpose for each tool: the SQLite project doesn't
470 accept outside contributions from previously-unknown developers, but
471 the Linux kernel does.
472
473 * <b>No rebasing:</b> When your local repo clone syncs changes
@@ -500,16 +519,16 @@
519 that everyone — especially the project leader — can maintain a better
520 mental picture of what is happening, leading to better situational
521 awareness.
522
523 By contrast, "…[https://docs.github.com/en/get-started/quickstart/contributing-to-projects|forking is
524 at the core of social coding at GitHub]". As of June 2026,
525 [https://github.com/search?q=is:public|Github hosts 324 million distinct
526 software projects], most of which were created by forking a
527 previously-existing project. Since this is
528 [https://www.griddynamics.com/blog/number-software-developers-world |
529 ~11× the number of developers in the world], it beggars belief that
530 most of these forks are still under active development. The vast bulk
531 of these must be abandoned one-off efforts. This is part of the nature
532 of bazaar style development.
533
534 You can think about this difference in terms of
@@ -533,18 +552,18 @@
552 <h4 id="scale">2.5.2 Scale</h4>
553
554 The Linux kernel has a far bigger developer community than that of
555 SQLite: there are thousands and thousands of contributors to Linux, most
556 of whom do not know each other's names. These thousands are responsible
557 for producing roughly 73× more code than is in SQLite. (32.0
558 [https://en.wikipedia.org/wiki/Source_lines_of_code|MLOC] vs. 0.44 MLOC
559 according to [https://github.com/boyter/scc | scc].) The Linux
560 kernel and its development process were already uncommonly large back in
561 2005 when Git was designed, specifically to support the consequences of
562 having such a large set of developers working on such a large code base.
563
564 95% of the code in SQLite comes from just six programmers, and 62% of
565 it is from the lead developer alone. The SQLite developers know each
566 other well and interact daily. Fossil was designed for this development
567 model.
568
569 When choosing your DVCS, we think you should ask yourself whether the
@@ -564,35 +583,33 @@
583
584 Both Fossil and Git store history as a directed acyclic graph (DAG)
585 of changes, but Git tends to focus more on individual branches of
586 the DAG, whereas Fossil puts more emphasis on the entire DAG.
587
588 While a common usage pattern in Git is to only synchronize
589 a single branch — <tt>git pull upstream feature/branch</tt> — instead
590 of all refs, Fossil does not give you a choice; it
591 syncs the entire DAG or nothing. Git commands,
592 GitHub, and GitLab tend to show only a single branch at
593 a time, whereas Fossil usually shows all parallel branches at
594 once. Git has commands like "rebase" that help keep all relevant
595 changes on a single branch, whereas Fossil encourages a style of
596 many concurrent branches constantly springing into existence,
597 undergoing active development in parallel for a few days or weeks, then
598 merging back into the main line and disappearing.
599
600 This difference in emphasis arises from the different purposes of
601 the two systems. Git's focus on individual branches
602 is exactly what you want for a highly-distributed bazaar-style project
603 such as Linux. Linus Torvalds does not want to see every check-in
604 by every contributor to Linux: such extreme visibility does not scale
605 well. Contrast Fossil, which was written for the cathedral-style SQLite project
606 and its handful of active committers. Seeing all
607 changes on all branches all at once helps keep the whole team
608 up-to-date with what everybody else is doing, resulting in a more
609 tightly focused and cohesive implementation.
610
 
 
 
611
612 <h3 id="checkouts">2.6 One vs. Many Check-outs per Repository</h3>
613
614 Because Git commingles the repository data with the initial checkout of
615 that repository, the default mode of operation in Git is to stick to that
@@ -612,20 +629,28 @@
629 standard advice is to use a switch-in-place workflow in Fossil when
630 the disturbance from switching branches is small, and to use multiple
631 checkouts when you have long-lived working branches that are different
632 enough that switching in place is disruptive.
633
634 While you can [./gitusers.md#worktree | use Git in the Fossil style] via
635 its worktree feature, tutorials continue to teach the style of having
636 one working directory only. Git can even fight you on this, as when
637 working with a forked repository; it is best to have independent clones
638 of the upstream and your fork, to allow separate "remote" lists and
639 such. This can result in two working directories but each having a
640 captive repo clone each, which isn't in the spirit of <tt>git
641 worktree</tt> at all. Yet, it beats the alternative, which then
642 highlights a gap in the model of diverging and re-converging forks.
643 Ideally, Git would let you create one of these forks as a worktree while
644 maintaining a strong separation between your fork and the upstream repo,
645 but it ends up being too much hassle to bother with.
646
647 There are
648 [https://duckduckgo.com/?q=git+worktree+problem | several known
649 problems] with the single worktree style, ones which don't happen in
650 Fossil because of the clear [./ckout-workflows.md | separation] between
651 a Fossil repository and each working directory.
652
653 This distinction matters because switching branches inside a single working directory loses local context
654 on each switch.
655
656 For instance, in any software project where the runnable program must be
@@ -658,13 +683,10 @@
683
684 Plus,
685 <tt>cd</tt> is faster to type than <tt>git checkout</tt> or <tt>fossil
686 update</tt>.
687
 
 
 
688 <h3 id="history">2.7 What you should have done vs. What you actually did</h3>
689
690 Git puts a lot of emphasis on maintaining
691 a "clean" check-in history. Extraneous and experimental branches by
692 individual developers often never make it into the main repository.
@@ -822,14 +844,14 @@
844 concepts to keep track of in your mental model of Fossil's internal
845 operation.
846
847 Fossil's implementation of the feature is also simpler to describe. The
848 brief online help for <tt>[/help/merge | fossil merge]</tt> is
849 currently 50 lines long, to which you want to add the ~800 lines of
850 [./branching.wiki | the branching document]. The equivalent
851 documentation in Git is the aggregation of the man pages for the above
852 three commands, which is approaching 1400 lines as of this writing, much of it mutually redundant.
853 (e.g. Git's <tt>--edit</tt> and <tt>--no-commit</tt> options get
854 described three times, each time differently.) Fossil's
855 documentation is not only more concise, it gives a nice split of brief
856 online help and full online documentation.
857
@@ -852,35 +874,42 @@
874 This not
875 only solves the SHAttered problem, it should prevent a reoccurrence of
876 similar problems for the foreseeable future.
877
878 Meanwhile, the Git community took until August 2018 to publish
879 [https://git-scm.com/docs/hash-function-transition/ | their plan] for
880 solving the same problem by moving to SHA-256, a variant of the
881 [https://en.wikipedia.org/wiki/SHA-2 | older SHA-2 algorithm]. That is
882 now technically implemented in the sense that <tt>git init
883 --object-format=sha256</tt> exists, but note well: this is not only an
884 optional setting, Git forge support is mixed, most notably
885 [https://github.com/GitoxideLabs/gitoxide/issues/281 | lacking in
886 GitHub], plus also BitBucket and others. This is doubtless because of
887 this warning in the latest ([https://git-scm.com/docs/git-init/2.54.0 |
888 as of this writing]) <tt>git init</tt> docs:
889
890 <blockquote>Note: At present, there is no interoperability between
891 SHA-256 repositories and SHA-1 repositories.</blockquote>
892
893 Although we are now in the <i>tenth year</i> of this situation, there
894 remains hope that Git will manage to make the transition without taking
895 the full decade: the
896 [https://www.deployhq.com/blog/git-3-0-on-the-horizon-what-git-users-need-to-know-about-the-next-major-release
897 | latest plan] is that Git 3.0 will finally <i>(finally!)</i> switch to
898 SHA256 by default, forcing the issue. Given the track record, we are
899 taking a "show me" stance on this claim.
900
901 Always remember, attacks only get better, never worse.
902 Almost three years after Fossil solved this problem, the
903 [https://sha-mbles.github.io/ | SHAmbles attack] was published, further
904 weakening the case for continuing to use SHA-1.
905
906 The practical impact of attacks like SHAttered and SHAmbles on the
907 Git and Fossil Merkle trees isn't clear, but you want to have your repositories
908 moved over to a stronger hash algorithm <i>before</i> someone figures out how
909 to make use of the weaknesses in the old one. Fossil's solution is long
910 since [https://repology.org/project/fossil/versions | universally deployed].
911
912 <hr/>
913
914 <h3>Asides and Digressions</h3>
915
@@ -910,16 +939,10 @@
939 lightweight web server,
940 <tt>[https://sqlite.org/althttpd/|althttpd]</tt>,
941 which is configured as a front end to Fossil running in CGI mode on
942 these sites.
943
 
 
 
 
 
 
944 <li><p>This means you can give up waiting for Fossil to be ported to
945 the PDP-11, but we remain hopeful that someone may eventually port
946 it to [https://en.wikipedia.org/wiki/Z/OS|z/OS].
947
948 <li><p>"Why is there all this Tcl in and around Fossil?" you may
949
--- www/javascript.md
+++ www/javascript.md
@@ -66,11 +66,11 @@
6666
Most JavaScript-based Fossil pages use less code than that.
6767
6868
Atop that, Fossil sends HTTP headers to the browser that allow it
6969
to perform aggressive caching so that typical page loads will skip
7070
re-loading this content on subsequent loads. These features are
71
- currently optional: you must either set the new
71
+ currently optional: you must either set the
7272
[`fossil server --jsmode bundle` option][fsrv] or the corresponding
7373
`jsmode` control line
7474
in your [`fossil cgi`][fcgi] script when setting up your
7575
[Fossil server][fshome]. That done, Fossil’s JavaScript files will
7676
load almost instantly from the browser’s cache after the initial
@@ -100,11 +100,11 @@
100100
Ajax partial page updates are faster than
101101
the no-JS alternative, a full HTTP POST round-trip to submit new
102102
data to the remote server, retrieve an entire new HTML document,
103103
and re-render the whole thing client-side.
104104
105
-3. <a id="3pjs"></a>“**Third-party JavaScript cannot be trusted.**”
105
+3. “<a id="3pjs"></a>**Third-party JavaScript cannot be trusted.**”
106106
107107
Fossil does not use any third-party JavaScript libraries, not even
108108
very common ones like jQuery. Every bit of JavaScript served by the
109109
stock version of Fossil was written specifically for the Fossil
110110
project and is stored [in its code repository][fsrc].
@@ -113,11 +113,11 @@
113113
Fossil and mechanisms like [skin editing][cskin] don’t suffice for your
114114
purposes, you can hack on the JavaScript in your local instance
115115
directly, just as you can hack on its C, SQL, and Tcl code. Fossil
116116
is free and open source software, under [a single license][2cbsd].
117117
118
-4. <a id="snoop"></a>“**JavaScript and cookies are used to snoop on web users.**”
118
+4. “<a id="snoop"></a>**JavaScript and cookies are used to snoop on web users.**”
119119
120120
There is no tracking or other snooping technology in Fossil other than
121121
that necessary for basic security, such as IP address logging on
122122
check-ins. (This is in part why we have no [comprehensive user
123123
statistics](#stats)!)
@@ -184,11 +184,11 @@
184184
The no-JS case is a [minority position](#stats), so those that want
185185
Fossil to have no-JS alternatives and graceful fallbacks will need
186186
to get involved with the development if they want this state of
187187
affairs to continue.
188188
189
-8. <a id="stats"></a>“**A large number of users run without JavaScript enabled.**”
189
+8. “<a id="stats"></a>**A large number of users run without JavaScript enabled.**”
190190
191191
That’s not what web audience measurements say:
192192
193193
* [What percentage of browsers with javascript disabled?][s1]
194194
* [How many people are missing out on JavaScript enhancement?][s2]
@@ -207,11 +207,11 @@
207207
run [powerful conditional blocking plugins](#block) in their
208208
browsers, rather than block JavaScript entirely. We suspect that
209209
between these two forces, the number of no-JS purists among Fossil’s
210210
user base is still a tiny minority.
211211
212
-9. <a id="block"></a>“**I block JavaScript entirely in my browser. That breaks Fossil.**”
212
+9. “<a id="block"></a>**I block JavaScript entirely in my browser. That breaks Fossil.**”
213213
214214
First, see our philosophy statements above. Briefly, we intend that
215215
there always be some other way to get any given result without using
216216
JavaScript, developer interest willing.
217217
@@ -237,11 +237,11 @@
237237
a few of these part-timers are responsible for the bulk of the code
238238
in Fossil. If you want Fossil to support such niche use cases, then
239239
you will have to [get involved with its development][cg]: it’s
240240
*your* uncommon itch.
241241
242
-11. <a id="compat"></a>“**Fossil’s JavaScript code isn’t compatible with my browser.**”
242
+11. “<a id="compat"></a>**Fossil’s JavaScript code isn’t compatible with my browser.**”
243243
244244
The Fossil project’s developers aim to remain compatible with
245245
the largest portions of the client-side browser base. We use only
246246
standards-defined JavaScript features which are known to work in the
247247
overwhelmingly vast majority of browsers going back approximately 5
248248
--- www/javascript.md
+++ www/javascript.md
@@ -66,11 +66,11 @@
66 Most JavaScript-based Fossil pages use less code than that.
67
68 Atop that, Fossil sends HTTP headers to the browser that allow it
69 to perform aggressive caching so that typical page loads will skip
70 re-loading this content on subsequent loads. These features are
71 currently optional: you must either set the new
72 [`fossil server --jsmode bundle` option][fsrv] or the corresponding
73 `jsmode` control line
74 in your [`fossil cgi`][fcgi] script when setting up your
75 [Fossil server][fshome]. That done, Fossil’s JavaScript files will
76 load almost instantly from the browser’s cache after the initial
@@ -100,11 +100,11 @@
100 Ajax partial page updates are faster than
101 the no-JS alternative, a full HTTP POST round-trip to submit new
102 data to the remote server, retrieve an entire new HTML document,
103 and re-render the whole thing client-side.
104
105 3. <a id="3pjs"></a>“**Third-party JavaScript cannot be trusted.**”
106
107 Fossil does not use any third-party JavaScript libraries, not even
108 very common ones like jQuery. Every bit of JavaScript served by the
109 stock version of Fossil was written specifically for the Fossil
110 project and is stored [in its code repository][fsrc].
@@ -113,11 +113,11 @@
113 Fossil and mechanisms like [skin editing][cskin] don’t suffice for your
114 purposes, you can hack on the JavaScript in your local instance
115 directly, just as you can hack on its C, SQL, and Tcl code. Fossil
116 is free and open source software, under [a single license][2cbsd].
117
118 4. <a id="snoop"></a>“**JavaScript and cookies are used to snoop on web users.**”
119
120 There is no tracking or other snooping technology in Fossil other than
121 that necessary for basic security, such as IP address logging on
122 check-ins. (This is in part why we have no [comprehensive user
123 statistics](#stats)!)
@@ -184,11 +184,11 @@
184 The no-JS case is a [minority position](#stats), so those that want
185 Fossil to have no-JS alternatives and graceful fallbacks will need
186 to get involved with the development if they want this state of
187 affairs to continue.
188
189 8. <a id="stats"></a>“**A large number of users run without JavaScript enabled.**”
190
191 That’s not what web audience measurements say:
192
193 * [What percentage of browsers with javascript disabled?][s1]
194 * [How many people are missing out on JavaScript enhancement?][s2]
@@ -207,11 +207,11 @@
207 run [powerful conditional blocking plugins](#block) in their
208 browsers, rather than block JavaScript entirely. We suspect that
209 between these two forces, the number of no-JS purists among Fossil’s
210 user base is still a tiny minority.
211
212 9. <a id="block"></a>“**I block JavaScript entirely in my browser. That breaks Fossil.**”
213
214 First, see our philosophy statements above. Briefly, we intend that
215 there always be some other way to get any given result without using
216 JavaScript, developer interest willing.
217
@@ -237,11 +237,11 @@
237 a few of these part-timers are responsible for the bulk of the code
238 in Fossil. If you want Fossil to support such niche use cases, then
239 you will have to [get involved with its development][cg]: it’s
240 *your* uncommon itch.
241
242 11. <a id="compat"></a>“**Fossil’s JavaScript code isn’t compatible with my browser.**”
243
244 The Fossil project’s developers aim to remain compatible with
245 the largest portions of the client-side browser base. We use only
246 standards-defined JavaScript features which are known to work in the
247 overwhelmingly vast majority of browsers going back approximately 5
248
--- www/javascript.md
+++ www/javascript.md
@@ -66,11 +66,11 @@
66 Most JavaScript-based Fossil pages use less code than that.
67
68 Atop that, Fossil sends HTTP headers to the browser that allow it
69 to perform aggressive caching so that typical page loads will skip
70 re-loading this content on subsequent loads. These features are
71 currently optional: you must either set the
72 [`fossil server --jsmode bundle` option][fsrv] or the corresponding
73 `jsmode` control line
74 in your [`fossil cgi`][fcgi] script when setting up your
75 [Fossil server][fshome]. That done, Fossil’s JavaScript files will
76 load almost instantly from the browser’s cache after the initial
@@ -100,11 +100,11 @@
100 Ajax partial page updates are faster than
101 the no-JS alternative, a full HTTP POST round-trip to submit new
102 data to the remote server, retrieve an entire new HTML document,
103 and re-render the whole thing client-side.
104
105 3. “<a id="3pjs"></a>**Third-party JavaScript cannot be trusted.**”
106
107 Fossil does not use any third-party JavaScript libraries, not even
108 very common ones like jQuery. Every bit of JavaScript served by the
109 stock version of Fossil was written specifically for the Fossil
110 project and is stored [in its code repository][fsrc].
@@ -113,11 +113,11 @@
113 Fossil and mechanisms like [skin editing][cskin] don’t suffice for your
114 purposes, you can hack on the JavaScript in your local instance
115 directly, just as you can hack on its C, SQL, and Tcl code. Fossil
116 is free and open source software, under [a single license][2cbsd].
117
118 4. “<a id="snoop"></a>**JavaScript and cookies are used to snoop on web users.**”
119
120 There is no tracking or other snooping technology in Fossil other than
121 that necessary for basic security, such as IP address logging on
122 check-ins. (This is in part why we have no [comprehensive user
123 statistics](#stats)!)
@@ -184,11 +184,11 @@
184 The no-JS case is a [minority position](#stats), so those that want
185 Fossil to have no-JS alternatives and graceful fallbacks will need
186 to get involved with the development if they want this state of
187 affairs to continue.
188
189 8. “<a id="stats"></a>**A large number of users run without JavaScript enabled.**”
190
191 That’s not what web audience measurements say:
192
193 * [What percentage of browsers with javascript disabled?][s1]
194 * [How many people are missing out on JavaScript enhancement?][s2]
@@ -207,11 +207,11 @@
207 run [powerful conditional blocking plugins](#block) in their
208 browsers, rather than block JavaScript entirely. We suspect that
209 between these two forces, the number of no-JS purists among Fossil’s
210 user base is still a tiny minority.
211
212 9. “<a id="block"></a>**I block JavaScript entirely in my browser. That breaks Fossil.**”
213
214 First, see our philosophy statements above. Briefly, we intend that
215 there always be some other way to get any given result without using
216 JavaScript, developer interest willing.
217
@@ -237,11 +237,11 @@
237 a few of these part-timers are responsible for the bulk of the code
238 in Fossil. If you want Fossil to support such niche use cases, then
239 you will have to [get involved with its development][cg]: it’s
240 *your* uncommon itch.
241
242 11. “<a id="compat"></a>**Fossil’s JavaScript code isn’t compatible with my browser.**”
243
244 The Fossil project’s developers aim to remain compatible with
245 the largest portions of the client-side browser base. We use only
246 standards-defined JavaScript features which are known to work in the
247 overwhelmingly vast majority of browsers going back approximately 5
248
--- www/server/any/cgi.md
+++ www/server/any/cgi.md
@@ -38,11 +38,13 @@
3838
(This might differ from the user the web server normally runs
3939
under.) The directory holding the repository file(s) needs to be
4040
writable so that SQLite can write its journal files. When using
4141
another access control system, such as AppArmor or SELinux, it may
4242
be necessary to explicitly permit that account to read and write
43
- the necessary files.
43
+ the necessary files. Also verify a possible _systemd_ sandboxing of
44
+ the web server service, especially the combination of _ProtectSystem_,
45
+ _ProtectHome_, and _ReadWriteDirectories_/_ReadWritePaths_.
4446
4547
* Fossil must be able to create temporary files in a
4648
[directory that varies by host OS](../../env-opts.md#temp). When the
4749
CGI process is operating [within a chroot](../../chroot.md),
4850
ensure that this directory exists and is readable/writeable by the
4951
--- www/server/any/cgi.md
+++ www/server/any/cgi.md
@@ -38,11 +38,13 @@
38 (This might differ from the user the web server normally runs
39 under.) The directory holding the repository file(s) needs to be
40 writable so that SQLite can write its journal files. When using
41 another access control system, such as AppArmor or SELinux, it may
42 be necessary to explicitly permit that account to read and write
43 the necessary files.
 
 
44
45 * Fossil must be able to create temporary files in a
46 [directory that varies by host OS](../../env-opts.md#temp). When the
47 CGI process is operating [within a chroot](../../chroot.md),
48 ensure that this directory exists and is readable/writeable by the
49
--- www/server/any/cgi.md
+++ www/server/any/cgi.md
@@ -38,11 +38,13 @@
38 (This might differ from the user the web server normally runs
39 under.) The directory holding the repository file(s) needs to be
40 writable so that SQLite can write its journal files. When using
41 another access control system, such as AppArmor or SELinux, it may
42 be necessary to explicitly permit that account to read and write
43 the necessary files. Also verify a possible _systemd_ sandboxing of
44 the web server service, especially the combination of _ProtectSystem_,
45 _ProtectHome_, and _ReadWriteDirectories_/_ReadWritePaths_.
46
47 * Fossil must be able to create temporary files in a
48 [directory that varies by host OS](../../env-opts.md#temp). When the
49 CGI process is operating [within a chroot](../../chroot.md),
50 ensure that this directory exists and is readable/writeable by the
51
+3 -3
--- www/sync.wiki
+++ www/sync.wiki
@@ -242,15 +242,15 @@
242242
243243
As of version 2.27, Fossil supports transfering of the login card
244244
externally to the request payload via a Cookie HTTP header:
245245
246246
<verbatim>
247
- Cookie: x-f-x-l=...
247
+ Cookie: x-f-l-c=...
248248
</verbatim>
249249
250
-Where "..." is the URL-encoded login cookie. <code>x-f-x-l</code> is
251
-short for X-Fossil-Xfer-Login.
250
+Where "..." is the URL-encoded login cookie. <code>x-f-l-c</code> is
251
+short for X-Fossil-Login-Cookie.
252252
253253
254254
<h3 id="file">3.3 File Cards</h3>
255255
256256
Artifacts are transferred using either "file" cards, or "cfile"
257257
--- www/sync.wiki
+++ www/sync.wiki
@@ -242,15 +242,15 @@
242
243 As of version 2.27, Fossil supports transfering of the login card
244 externally to the request payload via a Cookie HTTP header:
245
246 <verbatim>
247 Cookie: x-f-x-l=...
248 </verbatim>
249
250 Where "..." is the URL-encoded login cookie. <code>x-f-x-l</code> is
251 short for X-Fossil-Xfer-Login.
252
253
254 <h3 id="file">3.3 File Cards</h3>
255
256 Artifacts are transferred using either "file" cards, or "cfile"
257
--- www/sync.wiki
+++ www/sync.wiki
@@ -242,15 +242,15 @@
242
243 As of version 2.27, Fossil supports transfering of the login card
244 externally to the request payload via a Cookie HTTP header:
245
246 <verbatim>
247 Cookie: x-f-l-c=...
248 </verbatim>
249
250 Where "..." is the URL-encoded login cookie. <code>x-f-l-c</code> is
251 short for X-Fossil-Login-Cookie.
252
253
254 <h3 id="file">3.3 File Cards</h3>
255
256 Artifacts are transferred using either "file" cards, or "cfile"
257

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button