Fossil SCM

Support for status in forums.

drh 2026-05-29 10:10 UTC trunk merge
Commit 4c4c28dafeafcb0823c9dfcf00696a2d4970a7e62d87476dcdd532955a793701
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -288,11 +288,13 @@
288288
*/
289289
290290
button,
291291
html input[type="button"], /* 1 */
292292
input[type="reset"],
293
-input[type="submit"] {
293
+input[type="submit"],
294
+input[type="button"].submit,
295
+button.submit{
294296
-webkit-appearance: button; /* 2 */
295297
cursor: pointer; /* 3 */
296298
}
297299
298300
/**
@@ -517,11 +519,13 @@
517519
––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
518520
.button,
519521
button,
520522
input[type="button"],
521523
input[type="reset"],
522
-input[type="submit"] {
524
+input[type="submit"],
525
+input[type="button"].submit,
526
+button.submit{
523527
display: inline-block;
524528
height: 3.3rem;
525529
padding: 0 2.2rem;
526530
color: #555 !important;
527531
text-align: center;
@@ -551,24 +555,32 @@
551555
background-color: #eee;
552556
border-color: #aaa;
553557
outline: 0;
554558
}
555559
556
-input[type="submit"] {
560
+input[type="submit"],
561
+input[type="button"].submit,
562
+button.submit{
557563
color: white !important;
558564
background-color: #446979;
559565
border-color: #446979;
560566
}
561567
562568
input[type="submit"]:hover,
563
-input[type="submit"]:focus {
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{
564574
color: white !important;
565575
background-color: #648898;
566576
border-color: #648898;
567577
}
568578
569
-input[type="submit"]:disabled {
579
+input[type="submit"]:disabled,
580
+input[type="button"].submit:disabled,
581
+button.submit:disabled{
570582
color: rgb(128,128,128);
571583
background-color: rgb(153,153,153);
572584
}
573585
574586
575587
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -288,11 +288,13 @@
288 */
289
290 button,
291 html input[type="button"], /* 1 */
292 input[type="reset"],
293 input[type="submit"] {
 
 
294 -webkit-appearance: button; /* 2 */
295 cursor: pointer; /* 3 */
296 }
297
298 /**
@@ -517,11 +519,13 @@
517 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
518 .button,
519 button,
520 input[type="button"],
521 input[type="reset"],
522 input[type="submit"] {
 
 
523 display: inline-block;
524 height: 3.3rem;
525 padding: 0 2.2rem;
526 color: #555 !important;
527 text-align: center;
@@ -551,24 +555,32 @@
551 background-color: #eee;
552 border-color: #aaa;
553 outline: 0;
554 }
555
556 input[type="submit"] {
 
 
557 color: white !important;
558 background-color: #446979;
559 border-color: #446979;
560 }
561
562 input[type="submit"]:hover,
563 input[type="submit"]:focus {
 
 
 
 
564 color: white !important;
565 background-color: #648898;
566 border-color: #648898;
567 }
568
569 input[type="submit"]:disabled {
 
 
570 color: rgb(128,128,128);
571 background-color: rgb(153,153,153);
572 }
573
574
575
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -288,11 +288,13 @@
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 /**
@@ -517,11 +519,13 @@
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;
@@ -551,24 +555,32 @@
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
--- src/configure.c
+++ src/configure.c
@@ -143,10 +143,11 @@
143143
{ "hash-policy", CONFIGSET_PROJ },
144144
{ "comment-format", CONFIGSET_PROJ },
145145
{ "mimetypes", CONFIGSET_PROJ },
146146
{ "forbid-delta-manifests", CONFIGSET_PROJ },
147147
{ "mv-rm-files", CONFIGSET_PROJ },
148
+ { "forum-statuses", CONFIGSET_PROJ },
148149
{ "ticket-table", CONFIGSET_TKT },
149150
{ "ticket-common", CONFIGSET_TKT },
150151
{ "ticket-change", CONFIGSET_TKT },
151152
{ "ticket-newpage", CONFIGSET_TKT },
152153
{ "ticket-viewpage", CONFIGSET_TKT },
153154
--- src/configure.c
+++ src/configure.c
@@ -143,10 +143,11 @@
143 { "hash-policy", CONFIGSET_PROJ },
144 { "comment-format", CONFIGSET_PROJ },
145 { "mimetypes", CONFIGSET_PROJ },
146 { "forbid-delta-manifests", CONFIGSET_PROJ },
147 { "mv-rm-files", CONFIGSET_PROJ },
 
148 { "ticket-table", CONFIGSET_TKT },
149 { "ticket-common", CONFIGSET_TKT },
150 { "ticket-change", CONFIGSET_TKT },
151 { "ticket-newpage", CONFIGSET_TKT },
152 { "ticket-viewpage", CONFIGSET_TKT },
153
--- src/configure.c
+++ src/configure.c
@@ -143,10 +143,11 @@
143 { "hash-policy", CONFIGSET_PROJ },
144 { "comment-format", CONFIGSET_PROJ },
145 { "mimetypes", CONFIGSET_PROJ },
146 { "forbid-delta-manifests", CONFIGSET_PROJ },
147 { "mv-rm-files", CONFIGSET_PROJ },
148 { "forum-statuses", CONFIGSET_PROJ },
149 { "ticket-table", CONFIGSET_TKT },
150 { "ticket-common", CONFIGSET_TKT },
151 { "ticket-change", CONFIGSET_TKT },
152 { "ticket-newpage", CONFIGSET_TKT },
153 { "ticket-viewpage", CONFIGSET_TKT },
154
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,24 @@
11131113
11141114
div.setup_forum-column {
11151115
display: flex;
11161116
flex-direction: column;
11171117
}
1118
+
1119
+body.forum span.forum-status-selection {
1120
+ white-space: nowrap;
1121
+}
1122
+
1123
+body.cpage-forum div.forumPosts tr[data-status] td.status {
1124
+ /* Add a gap before the "X posts spanning Y time" labels,
1125
+ ** which sometimes wrap and look odd without this gap. */
1126
+ padding-left: 1em;
1127
+}
1128
+body.cpage-forum div.forumPosts tr[data-status="open"] {
1129
+}
1130
+body.cpage-forum div.forumPosts tr[data-status="resolved"] {
1131
+}
11181132
11191133
body.cpage-setup_forum > .content table {
11201134
margin-bottom: 1em;
11211135
}
11221136
body.cpage-setup_forum > .content table.bordered {
11231137
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,24 @@
1113
1114 div.setup_forum-column {
1115 display: flex;
1116 flex-direction: column;
1117 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
1119 body.cpage-setup_forum > .content table {
1120 margin-bottom: 1em;
1121 }
1122 body.cpage-setup_forum > .content table.bordered {
1123
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,24 @@
1113
1114 div.setup_forum-column {
1115 display: flex;
1116 flex-direction: column;
1117 }
1118
1119 body.forum span.forum-status-selection {
1120 white-space: nowrap;
1121 }
1122
1123 body.cpage-forum div.forumPosts tr[data-status] td.status {
1124 /* Add a gap before the "X posts spanning Y time" labels,
1125 ** which sometimes wrap and look odd without this gap. */
1126 padding-left: 1em;
1127 }
1128 body.cpage-forum div.forumPosts tr[data-status="open"] {
1129 }
1130 body.cpage-forum div.forumPosts tr[data-status="resolved"] {
1131 }
1132
1133 body.cpage-setup_forum > .content table {
1134 margin-bottom: 1em;
1135 }
1136 body.cpage-setup_forum > .content table.bordered {
1137
+577 -107
--- src/forum.c
+++ src/forum.c
@@ -61,12 +61,136 @@
6161
ForumPost *pDisplay; /* Entries in display order */
6262
ForumPost *pTail; /* Last on the display list */
6363
int mxIndent; /* Maximum indentation level */
6464
int nArtifact; /* Number of forum artifacts in this thread */
6565
};
66
+
67
+/*
68
+** A single entry from the forum-statuses setting.
69
+*/
70
+struct ForumStatus {
71
+ char *zLabel; /* Label for the UI */
72
+ char *zValue; /* status=X tag value */
73
+ char *zDescr; /* Brief description */
74
+};
75
+
76
+/*
77
+** A list of ForumStatus objects.
78
+*/
79
+struct ForumStatusList {
80
+ struct ForumStatus *aStatus; /* List of statuses */
81
+ unsigned int n; /* Number of entries */
82
+};
83
+
84
+/*
85
+** Information passed into the status_match() SQL function
86
+** via the sqlite3_user_data() mechanism, and used by status_match()
87
+** to determine whether or not a particular forum thread should
88
+** be displayed.
89
+*/
90
+struct ForumStatusMatch {
91
+ const ForumStatusList *pFses; /* Parsed forum-statuses setting */
92
+ int eStatusTag; /* tagid for the "status" property */
93
+ unsigned int iMatch; /* Match this status value */
94
+};
6695
#endif /* INTERFACE */
6796
97
+
98
+/*
99
+** Returns a high-level representation of the forum-statuses setting.
100
+** This is a singleton, cached across calls.
101
+ */
102
+static const ForumStatusList * forum_statuses(void){
103
+ static ForumStatusList fses = {0,0};
104
+ static int once = 0;
105
+ while( !once ){
106
+ ++once;
107
+ /* Read `forum-statuses` setting and transform it into the
108
+ ** fses object.
109
+ **
110
+ ** Maybe: if it's empty, synthesize a length-1 list from
111
+ ** {value:"default",label:"Default",...}. It's expected that
112
+ ** usage may be slightly simplified if we always have a non-empty
113
+ ** list. A length-1 list is, for purposes of the UI, identical to
114
+ ** an empty one - status selection/filtering makes no sense if
115
+ ** there's only one choice. */
116
+ db_multi_exec(
117
+ "CREATE TEMP TABLE IF NOT EXISTS forumstatus("
118
+ " ord INTEGER PRIMARY KEY,"
119
+ " label, value, descr"
120
+ ");"
121
+ "DELETE FROM forumstatus;"
122
+ "INSERT INTO forumstatus(label,value,descr)"
123
+ " WITH setting(v) AS ("
124
+ " SELECT value v FROM config WHERE name='forum-statuses'"
125
+ " ),"
126
+ " room(r) AS ("
127
+ " SELECT e.value FROM setting s, jsonb_each(s.v) e"
128
+ " WHERE json_valid(s.v, 0x02)"
129
+ " )"
130
+ " SELECT r->>'label', r->>'value', r->>'description'"
131
+ " FROM room;"
132
+ );
133
+ fses.n = (unsigned)db_int(0, "SELECT count(*) FROM forumstatus");
134
+ if( fses.n ){
135
+ int i = 0;
136
+ Stmt q;
137
+ db_prepare(&q,"SELECT label, value, descr FROM forumstatus"
138
+ " ORDER BY ord");
139
+ fses.aStatus = fossil_malloc(sizeof(fses.aStatus[0]) * fses.n);
140
+ while( SQLITE_ROW==db_step(&q) ){
141
+ ForumStatus * fs = &fses.aStatus[i++];
142
+ fs->zLabel = fossil_strdup(db_column_text(&q, 0));
143
+ fs->zValue = fossil_strdup(db_column_text(&q, 1));
144
+ fs->zDescr = fossil_strdup(db_column_text(&q, 2));
145
+ }
146
+ db_finalize(&q);
147
+ }
148
+ }
149
+ return &fses;
150
+}
151
+
152
+/*
153
+** Search for a ForumStatus object by its tag value. If a match is
154
+** found, the corresponding object is returned. If no match is found
155
+** then (A) if bFirst is false then 0 is returned, else (B) the first
156
+** entry in the list is returned, noting that the list may be empty,
157
+** in which case 0 is returned.
158
+*/
159
+const ForumStatus * forum_status_by_value(const char *z, int bFirst){
160
+ const ForumStatusList * const fses = forum_statuses();
161
+ const ForumStatus * fs0 = 0;
162
+ unsigned int i;
163
+ if( !fses->n ) return 0;
164
+ for( i = 0; i < fses->n; ++i ){
165
+ const ForumStatus * fs = &fses->aStatus[i];
166
+ if( 0==fossil_strcmp(z, fs->zValue) ){
167
+ return fs;
168
+ }else if( !fs0 ){
169
+ fs0 = fs;
170
+ }
171
+ }
172
+ return bFirst ? fs0 : 0;
173
+}
174
+
175
+/*
176
+** COMMAND: test-forum-statuses
177
+*/
178
+void test_forum_statuses_cmd(void){
179
+ const ForumStatusList * fses;
180
+ unsigned i;
181
+ db_find_and_open_repository(0,0);
182
+ fses = forum_statuses();
183
+ for(i = 0; i < fses->n; ++i ){
184
+ const ForumStatus * fs = &fses->aStatus[i];
185
+ fossil_print("Status: %!j %!j %!j\n",
186
+ fs->zValue, fs->zLabel, fs->zDescr);
187
+ assert( fs==forum_status_by_value(fs->zValue, 0) );
188
+ }
189
+ fossil_print("Total statuses: %u\n", i);
190
+}
191
+
68192
/*
69193
** Return true if the forum post with the given rid has been
70194
** subsequently edited.
71195
*/
72196
int forum_rid_has_been_edited(int rid){
@@ -124,14 +248,24 @@
124248
** matches the event.(euser,user) field for a formpost entry with the
125249
** matching RID. Returns false if no match is found. If zUserName is
126250
** 0 then login_name() is used.
127251
*/
128252
int forumpost_is_owner(int rid, const char *zUserName){
129
- return db_int(0, "SELECT 1 FROM event "
130
- "WHERE type='f' AND objid=%d "
131
- "AND coalesce(euser,user)=%Q",
132
- rid, zUserName ? zUserName : login_name());
253
+ static Stmt q;
254
+ int rc;
255
+ if( !q.pStmt ){
256
+ db_static_prepare(
257
+ &q, "SELECT 1 FROM event"
258
+ " WHERE type='f' AND objid=$rid"
259
+ " AND coalesce(euser,user)=$user"
260
+ );
261
+ }
262
+ db_bind_int(&q, "$rid", rid);
263
+ db_bind_text(&q, "$user", zUserName ? zUserName : login_name());
264
+ rc = SQLITE_ROW==db_step(&q);
265
+ db_reset(&q);
266
+ return rc;
133267
}
134268
135269
/*
136270
** Returns true if p, or any parent of p, has a non-zero iClosed
137271
** value. Returns 0 if !p. For an edited chain of post, the tag is
@@ -174,12 +308,12 @@
174308
** - 0 if no matching tag is found.
175309
**
176310
** - The tagxref.rowid of the tagxref entry for the closure if rid is
177311
** the forum post to which the closure applies.
178312
**
179
-** - (-tagxref.rowid) if the given rid inherits a "closed" tag from an
180
-** IRT forum post.
313
+** - (-tagxref.rowid) if the given rid inherits the tag from an IRT
314
+** forum post.
181315
*/
182316
static int forum_rid_is_tagged(int rid, const char *zTagName, int bCheckIrt){
183317
static Stmt qIrt = empty_Stmt_m;
184318
int rc = 0, i = 0;
185319
/* TODO: this can probably be turned into a CTE by someone with
@@ -266,25 +400,27 @@
266400
** provide consistent behavior, it always acts on the first version of
267401
** the given forum post, walking the forumpost.fprev values to find
268402
** the head of the chain.
269403
**
270404
** If addTag is true then a propagating tag is added, except as noted
271
-** below, with the given optional zReason string as the tag's
405
+** below, with the given optional zValue string as the tag's
272406
** value. If addTag is false then any matching active tag on frid is
273
-** cancelled, except as noted below. zReason is ignored if it is NULL
407
+** cancelled, except as noted below. zValue is ignored if it is NULL
274408
** or starts with a NUL byte, or if addTag is false.
275409
**
276410
** This function only adds a tag if forum_rid_is_tagged() indicates
277411
** that frid's head is not tagged. If a parent post is already tagged,
278412
** no tag is added. Similarly, it will only remove a tag from a post
279413
** which has its own tag, and will not remove an inherited one from a
280414
** parent post.
281415
**
282
-** If addTag is true and frid is already tagged (directly or
283
-** inherited), this is a no-op. Likewise, if addTag is false and frid
284
-** itself is not tagged (not accounting for an inherited closed tag),
285
-** this is a no-op.
416
+** If addTag is true and frid is already tagged, this is a
417
+** no-op. Likewise, if addTag is false and frid is not tagged
418
+** (not accounting for an inherited closed tag), this is a no-op.
419
+**
420
+** If bCheckIrt is true then the forum post IRT hierarchy is searched
421
+** for the tag, otherwise only the given RID is checked.
286422
**
287423
** Returns true if it actually creates a new tag, else false. Fails
288424
** fatally on error.
289425
**
290426
** If it returns true then state from previously-loaded posts may be
@@ -307,57 +443,62 @@
307443
** - The applied tag is propagating so so that "closed" tags can
308444
** account for how edits of posts are handled. This differs from
309445
** closure of a branch, where a non-propagating tag is used.
310446
*/
311447
static int forumpost_tag(int frid, const char *zTagName, int addTag,
312
- const char *zReason){
448
+ const char *zValue){
313449
Blob artifact = BLOB_INITIALIZER; /* Output artifact */
314450
Blob cksum = BLOB_INITIALIZER; /* Z-card */
315451
int iTagged; /* true if frid is already tagged */
316452
int trid; /* RID of new control artifact */
317453
char *zUuid; /* UUID of head version of post */
318454
319455
db_begin_transaction();
320456
frid = forumpost_head_rid(frid);
321
- iTagged = forum_rid_is_tagged(frid, "closed", 1);
322
- if( (iTagged && addTag
323
- /* Already tagged, noting that in the case of (addTag<0) it may
324
- ** actually be a parent which is tagged. */)
325
- || (iTagged<=0 && !addTag
326
- /* This entry is not tagged, but a parent post may be. */) ){
457
+ iTagged = forum_rid_is_tagged(frid, zTagName, 0);
458
+ if( !addTag && !iTagged ){
459
+ /* Nothing to do. We never tag an IRT-inherited post via this
460
+ ** function. */
327461
db_end_transaction(0);
328462
return 0;
329463
}
330
- if( addTag==0 || (zReason && !zReason[0]) ){
331
- zReason = 0;
464
+ if( !addTag || (zValue && !zValue[0]) ){
465
+ zValue = 0;
466
+ }
467
+ if( addTag && iTagged ){
468
+ char *zOld = 0;
469
+ int cmp;
470
+ rid_has_tag2(iTagged, zTagName, &zOld);
471
+ cmp = fossil_strcmp(zOld, zValue);
472
+ fossil_free(zOld);
473
+ if( 0==cmp ){
474
+ /* Same value - leave it as is. */
475
+ db_end_transaction(0);
476
+ return 0;
477
+ }
332478
}
333479
zUuid = rid_to_uuid(frid);
334480
blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" ));
335481
blob_appendf(&artifact, "T %c%s %s%s%F\n",
336482
addTag ? '*' : '-', zTagName,
337
- zUuid, zReason ? " " : "", zReason ? zReason : "");
483
+ zUuid, zValue ? " " : "", zValue ? zValue : "");
338484
blob_appendf(&artifact, "U %F\n", login_name());
339485
md5sum_blob(&artifact, &cksum);
340486
blob_appendf(&artifact, "Z %b\n", &cksum);
341487
blob_reset(&cksum);
342488
trid = content_put_ex(&artifact, 0, 0, 0, 0);
343489
if( trid==0 ){
344490
fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
345491
}
346
- if( manifest_crosslink(trid, &artifact,
347
- MC_NONE /*MC_PERMIT_HOOKS?*/)==0 ){
492
+ if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
348493
fossil_fatal("%s", g.zErrMsg);
349494
}
350495
assert( blob_is_reset(&artifact) );
351496
db_add_unsent(trid);
352497
admin_log("Tag forum post %S with %c%s",
353498
zUuid, addTag ? '*' : '-', zTagName);
354499
fossil_free(zUuid);
355
- /* Potential TODO: if (iClosed>0) then we could find the initial tag
356
- ** artifact and content_deltify(thatRid,&trid,1,0). Given the tiny
357
- ** size of these artifacts, however, that would save little space,
358
- ** if any. */
359500
db_end_transaction(0);
360501
return 1;
361502
}
362503
363504
/*
@@ -722,10 +863,71 @@
722863
}
723864
@ </table>
724865
db_finalize(&q);
725866
style_finish_page();
726867
}
868
+
869
+/*
870
+** Returns true if the current user is authorized to set forum post
871
+** fpid's status.
872
+*/
873
+static int forum_may_set_status(int fpid){
874
+ return g.perm.Admin
875
+ || g.perm.ModForum
876
+ || (login_is_individual()
877
+ && forumpost_is_owner(fpid, 0));
878
+}
879
+
880
+/*
881
+** If the current user is authorized to set fp's status then this
882
+** renders a mini-form for setting the status then redirecting back to
883
+** the post. Else it may emit a status label or no output.
884
+*/
885
+static void forum_render_status_selection( const ForumPost *fp ){
886
+ const ForumStatusList * const fss = forum_statuses();
887
+ if( fss->n>1 ){
888
+ const ForumPost * pHead = fp->pEditHead ? fp->pEditHead : fp;
889
+ int i;
890
+ char * zCurrent = 0;
891
+ const ForumStatus * sCurrent = 0;
892
+ rid_has_tag2(pHead->fpid, "status", &zCurrent);
893
+ for( i = 0; i < fss->n; ++i ){
894
+ const ForumStatus * const fs = &fss->aStatus[i];
895
+ if( 0==fossil_strcmp(zCurrent, fs->zValue) ){
896
+ sCurrent = fs;
897
+ break;
898
+ }
899
+ }
900
+ if( !sCurrent ) sCurrent = &fss->aStatus[0];
901
+ assert( sCurrent );
902
+ @ <span class='forum-status-selection'>
903
+ if( forum_may_set_status(fp->fpid) ){
904
+ @ <form method="post" action='%R/forumpost_status'>
905
+ login_insert_csrf_secret();
906
+ @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
907
+ @ <select name='status' data-fpid='%s(fp->zUuid)'\
908
+ @ data-initial-value='%h(zCurrent ? zCurrent : "")'>
909
+ for( i = 0; i < fss->n; ++i ){
910
+ const ForumStatus * const fs = &fss->aStatus[i];
911
+ @ <option value='%h(fs->zValue)'\
912
+ @ %s(sCurrent==fs ? " selected" : "")>\
913
+ @ %h(fs->zLabel)</option>
914
+ }
915
+ @ </select>
916
+ @ <input type='button' class='submit action-status' disabled
917
+ @ value='Change' />
918
+ /* ^^^ This must be <input>, not <button>, or else tapping it
919
+ ** will unconditionally submit. */
920
+ @ </form>
921
+ /* Form is activated in fossil.page.forumpost.js */
922
+ }else{
923
+ @ <button disabled>Status: %h(sCurrent->zLabel)</button>
924
+ }
925
+ @ </span>
926
+ fossil_free(zCurrent);
927
+ }
928
+}
727929
728930
/*
729931
** Render a forum post for display
730932
*/
731933
void forum_render(
@@ -863,21 +1065,24 @@
8631065
static void forum_render_attachment_list2(ForumPost *p){
8641066
if( p->pEditHead ) p = p->pEditHead;
8651067
forum_render_attachment_list(p->zUuid);
8661068
}
8671069
1070
+/* Flags for use with forum_display_post() */
1071
+#define FDISPLAY_RAW 0x01 /* omit the border */
1072
+#define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
1073
+#define FDISPLAY_HISTORY 0x04 /* Showing edit history */
1074
+#define FDISPLAY_SELECTED 0x08 /* This is the selected post */
1075
+
8681076
/*
8691077
** Display a single post in a forum thread.
8701078
*/
8711079
static void forum_display_post(
8721080
ForumThread *pThread, /* The thread that this post is a member of */
8731081
ForumPost *p, /* Forum post to display */
8741082
int iIndentScale, /* Indent scale factor */
875
- int bRaw, /* True to omit the border */
876
- int bUnf, /* True to leave the post unformatted */
877
- int bHist, /* True if showing edit history */
878
- int bSelect, /* True if this is the selected post */
1083
+ int flags, /* From the FDISPLAY_... enum */
8791084
char *zQuery /* Common query string */
8801085
){
8811086
char *zPosterName; /* Name of user who originally made this post */
8821087
char *zEditorName; /* Name of user who provided the current edit */
8831088
char *zDate; /* The time/date string */
@@ -886,10 +1091,14 @@
8861091
Manifest *pManifest; /* Manifest comprising the current post */
8871092
int bPrivate; /* True for posts awaiting moderation */
8881093
int bSameUser; /* True if author is also the reader */
8891094
int iIndent; /* Indent level */
8901095
int iClosed; /* True if (sub)thread is closed */
1096
+ const int bRaw = flags & FDISPLAY_RAW;
1097
+ const int bUnf = flags & FDISPLAY_UNFORMATTED;
1098
+ const int bHist = flags & FDISPLAY_HISTORY;
1099
+ const int bSelect = flags & FDISPLAY_SELECTED;
8911100
const char *zMimetype;/* Formatting MIME type */
8921101
8931102
/* Get the manifest for the post. Abort if not found (e.g. shunned). */
8941103
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
8951104
if( !pManifest ) return;
@@ -990,11 +1199,11 @@
9901199
/* Provide a link to the raw source code. */
9911200
if( !bUnf ){
9921201
@ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
9931202
}
9941203
@ </h3>
995
- }
1204
+ }/*!bRaw*/
9961205
9971206
/* Check if this post is approved, also if it's by the current user. */
9981207
bPrivate = content_is_private(p->fpid);
9991208
bSameUser = login_is_individual()
10001209
&& fossil_strcmp(pManifest->zUser, g.zLogin)==0;
@@ -1058,14 +1267,16 @@
10581267
const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
10591268
if( forumpost_may_close() && iClosed>=0 ){
10601269
@ <form method="post" \
10611270
@ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
10621271
login_insert_csrf_secret();
1063
- @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1272
+ @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
10641273
if( moderation_pending(p->fpid)==0 ){
10651274
@ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1066
- @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
1275
+ @ class='submit hidden \
1276
+ @ %s(iClosed ? "action-reopen" : "action-close")'/>
1277
+ /* ^^^ activated by fossil.page.forumpost.js */
10671278
}
10681279
@ </form>
10691280
}
10701281
if( g.perm.Admin ||
10711282
(login_is_individual()
@@ -1082,12 +1293,15 @@
10821293
@ </form>
10831294
}
10841295
}
10851296
@ </div>
10861297
}
1298
+ if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
1299
+ forum_render_status_selection(p);
1300
+ }
10871301
@ </div>
1088
- }
1302
+ }/*!bRaw*/
10891303
10901304
/* Clean up. */
10911305
manifest_destroy(pManifest);
10921306
}
10931307
@@ -1197,12 +1411,18 @@
11971411
}
11981412
11991413
/* Display the appropriate subset of posts in sequence. */
12001414
while( p ){
12011415
/* Display the post. */
1202
- forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1203
- bUnf, bHist, p==pSelect, zQuery);
1416
+ forum_display_post(
1417
+ pThread, p, iIndentScale,
1418
+ (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1419
+ (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1420
+ (bHist ? FDISPLAY_HISTORY : 0) |
1421
+ (p==pSelect ? FDISPLAY_SELECTED : 0),
1422
+ zQuery
1423
+ );
12041424
12051425
/* Advance to the next post in the thread. */
12061426
if( mode==FD_CHRONO ){
12071427
/* Chronological mode: display posts (optionally including edits) in their
12081428
** original commit order. */
@@ -1572,44 +1792,82 @@
15721792
mimetype_option_menu(zMimetype, "mimetype");
15731793
@ <div class="forum-editor-widget">
15741794
@ <textarea aria-label="Content:" name="content" class="wikiedit" \
15751795
@ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
15761796
}
1797
+
1798
+/*
1799
+** If PD("fpid") refers to a forum post, its rid is returned, else
1800
+** this function emits an error does not does return.
1801
+*/
1802
+static int forum_validate_fpid_param(void){
1803
+ const char *zFpid = PD("fpid","");
1804
+ int fpid = symbolic_name_to_rid(zFpid, "f");
1805
+ if( fpid<=0 ){
1806
+ webpage_error("Missing or invalid fpid parameter.");
1807
+ }
1808
+ return fpid;
1809
+}
1810
+
1811
+/*
1812
+** Internal helper for /forumpost_XYZ internal pages which tag/untag
1813
+** posts.
1814
+*/
1815
+static void forumpost_action_helper(const char *zTag, const char *zVal,
1816
+ int addTag, int validFpid){
1817
+ if( !cgi_csrf_safe(2) ){
1818
+ webpage_error("CSRF validation failed");
1819
+ }else{
1820
+ const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1821
+ forumpost_tag(fpid, zTag, addTag, zVal);
1822
+ cgi_redirectf("%R/forumpost/%S",P("fpid"));
1823
+ }
1824
+}
15771825
15781826
/*
15791827
** WEBPAGE: forumpost_close hidden
15801828
** WEBPAGE: forumpost_reopen hidden
15811829
**
15821830
** fpid=X Hash of the post to be edited. REQUIRED
1583
-** reason=X Optional reason for closure.
15841831
**
15851832
** Closes or re-opens the given forum post, within the bounds of the
15861833
** API for forumpost_tag(). After (perhaps) modifying the "closed"
15871834
** status of the given thread, it redirects to that post's thread
15881835
** view. Requires admin privileges.
15891836
*/
15901837
void forum_page_close(void){
1591
- const char *zFpid = PD("fpid","");
1592
- const char *zReason = 0;
1593
- int fClose;
1594
- int fpid;
1595
-
15961838
login_check_credentials();
15971839
if( forumpost_may_close()==0 ){
15981840
login_needed(g.anon.Admin);
1599
- return;
1600
- }
1601
- cgi_csrf_verify();
1602
- fpid = symbolic_name_to_rid(zFpid, "f");
1603
- if( fpid<=0 ){
1604
- webpage_error("Missing or invalid fpid query parameter");
1605
- }
1606
- fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1607
- if( fClose ) zReason = PD("reason",0);
1608
- forumpost_tag(fpid, "closed", fClose, zReason);
1609
- cgi_redirectf("%R/forumpost/%S",zFpid);
1610
- return;
1841
+ }else{
1842
+ const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1843
+ forumpost_action_helper("closed", 0, bIsAdd, 0);
1844
+ }
1845
+}
1846
+
1847
+/*
1848
+** WEBPAGE: forumpost_status hidden
1849
+**
1850
+** fpid=X Hash of the post to be edited. REQUIRED
1851
+** status=Y New status value. REQUIRED
1852
+**
1853
+** Updates the current status=Y tag on the first version of
1854
+** the forum post X. Requires forum_may_set_status() permissions.
1855
+*/
1856
+void forum_page_status(void){
1857
+ int fpid;
1858
+ login_check_credentials();
1859
+ fpid = forum_validate_fpid_param();
1860
+ if(forum_may_set_status(fpid)){
1861
+ const char *zStatus = PD("status",0);
1862
+ if( !zStatus || !zStatus[0] ){
1863
+ webpage_error("Missing required status.");
1864
+ }
1865
+ forumpost_action_helper("status", zStatus, 1, fpid);
1866
+ }else{
1867
+ webpage_error("You lack permissions to change this post's status.");
1868
+ }
16111869
}
16121870
16131871
/*
16141872
** WEBPAGE: forumnew
16151873
** WEBPAGE: forumedit
@@ -1788,11 +2046,13 @@
17882046
fpid = symbolic_name_to_rid(zFpid, "f");
17892047
if( fpid<=0 || (pPost = manifest_get(fpid, CFTYPE_FORUM, 0))==0 ){
17902048
webpage_error("Missing or invalid fpid query parameter");
17912049
}
17922050
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid);
1793
- if( froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0 ){
2051
+ if( (froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0)
2052
+ && P("reject")==0
2053
+ ){
17942054
webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid);
17952055
}
17962056
if( P("cancel") ){
17972057
cgi_redirectf("%R/forumpost/%S",zFpid);
17982058
return;
@@ -1968,10 +2228,21 @@
19682228
** seems more appropriate for the particular usage.
19692229
**
19702230
** SETTING: attachment-size-limit width=16
19712231
** The maximum number of bytes for an attachment. The default (or 0) is
19722232
** unlimited but a limit may be imposed by the web server or a proxy.
2233
+**
2234
+** SETTING: forum-statuses width=40 block-text
2235
+** This JSON5-formatted value defines an array of objects describing
2236
+** the available statuses of forum posts. Each entry of the array must
2237
+** be an object in the form {label:"X",value:"Y",description:"Z"}.
2238
+** The label is used in the UI and value becomes the value of the
2239
+** "status" tag on forum posts. Any forum post which has a status
2240
+** value which does not appear in this list is treated as if it had
2241
+** the first value from this list. If this setting is empty, is
2242
+** ill-formed JSON, or has only a single entry then the forum will
2243
+** lack the capability of setting and filtering by status.
19732244
*/
19742245
19752246
/*
19762247
** WEBPAGE: setup_forum
19772248
**
@@ -2098,10 +2369,104 @@
20982369
@ </form>
20992370
}
21002371
21012372
style_finish_page();
21022373
}
2374
+
2375
+/*
2376
+** If the forum-statuses setting is active and has 2 or more entries,
2377
+** this adds a submenu for selecting the status filter, else it emits
2378
+** nothing.
2379
+*/
2380
+static void forum_status_submenu(void){
2381
+ const ForumStatusList * const fss = forum_statuses();
2382
+ static int i = 0;
2383
+ static const char **az;
2384
+ if( i==0 && fss->n>1 ){
2385
+ unsigned j;
2386
+ az = fossil_malloc(sizeof(az[0]) * ((1 + fss->n) * 2));
2387
+ az[i++] = "*";
2388
+ az[i++] = "Any status";
2389
+ for( j = 0; j < fss->n; ++j ){
2390
+ const ForumStatus * fs = &fss->aStatus[j];
2391
+ /* Potential TODO: skip any entries for which there are no
2392
+ ** forum posts with a status=${fs->zValue} tag. */
2393
+ az[i++] = fs->zValue;
2394
+ az[i++] = fs->zLabel;
2395
+ }
2396
+ //assert( i==(1+fss->n)*2 );
2397
+ }
2398
+ if( i ){
2399
+ cookie_link_parameter("status","forumStatus","*");
2400
+ style_submenu_multichoice("status", i/2, az, 0);
2401
+ }
2402
+}
2403
+
2404
+/*
2405
+** Transient SQL Function: status_match(FROOT)
2406
+**
2407
+** Return true if the forum thread identified by FROOT should be included
2408
+** in a list of threads. Used to implement the status=NAME query parameter
2409
+** on /forum.
2410
+**
2411
+** The result of this routine depends on the content of the
2412
+** ForumStatusMatch *pMData object that is available via sqlite3_user_data().
2413
+**
2414
+** * If pMData==NULL, always return true. This means that no
2415
+** filtering of threads is being done. This is the common case.
2416
+**
2417
+** * If FROOT contains a status property value that matches
2418
+** pMData->iMatch, return true.
2419
+**
2420
+** * if pMData->iMatch==0 (meaning we want to match the default
2421
+** status value) and if the FROOT thread contains a status that
2422
+** is not on the list of statuses or if FROOT has no statue
2423
+** property at all, then return true. In other words, a forum
2424
+** thread with no status property or an unknown status property
2425
+** is treated as if it had the default status.
2426
+**
2427
+** * Otherwise, return false.
2428
+*/
2429
+static void forum_status_match(
2430
+ sqlite3_context *context,
2431
+ int argc,
2432
+ sqlite3_value **argv
2433
+){
2434
+ static Stmt q;
2435
+ ForumStatusMatch *pMData = sqlite3_user_data(context);
2436
+ int i;
2437
+
2438
+ if( pMData==0 ){
2439
+ sqlite3_result_int(context, 1);
2440
+ return;
2441
+ }
2442
+ db_static_prepare(&q,
2443
+ "SELECT value FROM tagxref\n"
2444
+ " WHERE tagid=%d\n"
2445
+ " AND tagtype>=1\n"
2446
+ " AND rid=:rid\n"
2447
+ " ORDER BY mtime DESC LIMIT 1",
2448
+ pMData->eStatusTag
2449
+ );
2450
+ db_bind_int(&q, ":rid", sqlite3_value_int(argv[0]));
2451
+ if( db_step(&q)==SQLITE_ROW ){
2452
+ const char *zValue = (const char*)db_column_text(&q,0);
2453
+ const ForumStatusList *pFses = pMData->pFses;
2454
+ if( zValue==0 ){
2455
+ i = 0;
2456
+ }else{
2457
+ for(i=0; i<pFses->n; i++){
2458
+ if( fossil_strcmp(pFses->aStatus[i].zValue,zValue)==0 ) break;
2459
+ }
2460
+ }
2461
+ if( i>=pMData->pFses->n ) i = 0;
2462
+ }else{
2463
+ i = 0;
2464
+ }
2465
+ db_reset(&q);
2466
+ sqlite3_result_int(context, i==pMData->iMatch);
2467
+}
21032468
21042469
/*
21052470
** WEBPAGE: forummain
21062471
** WEBPAGE: forum
21072472
**
@@ -2118,30 +2483,35 @@
21182483
void forum_main_page(void){
21192484
Stmt q;
21202485
int iLimit = 0, iOfst, iCnt;
21212486
int srchFlags;
21222487
const int isSearch = P("s")!=0;
2123
- char const *zLimit = 0;
2488
+ const char *zStatusFilter;
2489
+ char const *zLimit = 0; /* Value of the n= query parameter */
2490
+ int eStatusTag = 0; /* tagid for the "status" property */
2491
+ int bHasStatus = 0; /* True if forum-statuses setting exists */
2492
+ int bFilter = 0; /* True if status=NAME query parameter */
2493
+ ForumStatusMatch sFSM; /* Aux data to status_match() SQL function */
21242494
21252495
login_check_credentials();
21262496
srchFlags = search_restrict(SRCH_FORUM);
21272497
if( !g.perm.RdForum ){
21282498
login_needed(g.anon.RdForum);
21292499
return;
21302500
}
21312501
cgi_check_for_malice();
2502
+ eStatusTag = db_int(0, "SELECT tagid FROM tag WHERE tagname='status'");
2503
+ if( eStatusTag && forum_statuses()->n>1 ){
2504
+ bHasStatus = 1;
2505
+ }
21322506
style_set_current_feature("forum");
21332507
style_header("%s%s", db_get("forum-title","Forum"),
21342508
isSearch ? " Search Results" : "");
21352509
style_submenu_element("Timeline", "%R/timeline?ss=v&y=f&vfx");
21362510
if( g.perm.WrForum ){
21372511
style_submenu_element("New Thread","%R/forumnew");
21382512
}else{
2139
- /* Can't combine this with previous case using the ternary operator
2140
- * because that causes an error yelling about "non-constant format"
2141
- * with some compilers. I can't see it, since both expressions have
2142
- * the same format, but I'm no C spec lawyer. */
21432513
style_submenu_element("New Thread","%R/login");
21442514
}
21452515
if( g.perm.ModForum && moderation_needed() ){
21462516
style_submenu_element("Moderation Requests", "%R/modreq");
21472517
}
@@ -2164,95 +2534,195 @@
21642534
cgi_replace_query_parameter("n", fossil_strdup("25"))
21652535
/*for the sake of Max, below*/;
21662536
iLimit = 25;
21672537
}
21682538
style_submenu_entry("n","Max:",4,0);
2539
+ forum_status_submenu();
2540
+ zStatusFilter = P("status") /*must be after forum_status_submenu()!*/;
21692541
iOfst = atoi(PD("x","0"));
21702542
iCnt = 0;
2543
+ if( zStatusFilter ){
2544
+ if( zStatusFilter[0]==0 || 0==fossil_strcmp("*",zStatusFilter) ){
2545
+ zStatusFilter = 0;
2546
+ }else{
2547
+ bFilter = bHasStatus;
2548
+ }
2549
+ }
21712550
if( db_table_exists("repository","forumpost") ){
2551
+ const ForumStatusList *pFstat = forum_statuses();
2552
+ Stmt qStat = empty_Stmt; /* Query to get status information */
2553
+ if( bHasStatus ){
2554
+ /* The qStat query runs once for each output row generate by the
2555
+ ** q query. It determines the value and label of the status for
2556
+ ** the row with froot=:rowid
2557
+ */
2558
+ db_prepare(&qStat,
2559
+ "SELECT tagxref.value, forumstatus.label\n"
2560
+ " FROM forumstatus, tagxref\n"
2561
+ " WHERE tagid=%d AND tagtype>=1\n"
2562
+ " AND forumstatus.value=tagxref.value\n"
2563
+ " AND rid=:rid\n"
2564
+ " ORDER BY mtime DESC",
2565
+ eStatusTag
2566
+ );
2567
+ }
2568
+
2569
+ /* Create the status_match() SQL function that will determine
2570
+ ** whether or not each thread in the "q" query below is eligible
2571
+ ** for display
2572
+ */
2573
+ if( bFilter ){
2574
+ sFSM.pFses = pFstat;
2575
+ sFSM.eStatusTag = eStatusTag;
2576
+ for(sFSM.iMatch=0; sFSM.iMatch<pFstat->n; sFSM.iMatch++){
2577
+ if( 0==fossil_strcmp(zStatusFilter,
2578
+ pFstat->aStatus[sFSM.iMatch].zValue) ){
2579
+ break;
2580
+ }
2581
+ }
2582
+ sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,(void*)&sFSM,
2583
+ forum_status_match, 0, 0);
2584
+ }else{
2585
+ sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0,
2586
+ forum_status_match, 0, 0);
2587
+ }
2588
+
21722589
db_prepare(&q,
2173
- "WITH thread(age,duration,cnt,root,last) AS ("
2174
- " SELECT"
2175
- " julianday('now') - max(fmtime),"
2176
- " max(fmtime) - min(fmtime),"
2177
- " sum(fprev IS NULL),"
2178
- " froot,"
2179
- " (SELECT fpid FROM forumpost AS y"
2180
- " WHERE y.froot=x.froot %s"
2181
- " ORDER BY y.fmtime DESC LIMIT 1)"
2182
- " FROM forumpost AS x"
2183
- " WHERE %s"
2184
- " GROUP BY froot"
2185
- " ORDER BY 1 LIMIT %d OFFSET %d"
2186
- ")"
2187
- "SELECT"
2188
- " thread.age," /* 0 */
2189
- " thread.duration," /* 1 */
2190
- " thread.cnt," /* 2 */
2191
- " blob.uuid," /* 3 */
2192
- " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2193
- " thread.last" /* 5 */
2194
- " FROM thread, blob, event"
2195
- " WHERE blob.rid=thread.last"
2196
- " AND event.objid=thread.last"
2590
+ "WITH thread(root,endtime,lastrid) AS (\n"
2591
+ " SELECT\n"
2592
+ " froot,\n"
2593
+ " max(fmtime),\n"
2594
+ " fpid\n"
2595
+ " FROM forumpost\n"
2596
+ " WHERE %s/*ModForum*/\n"
2597
+ " GROUP BY froot\n"
2598
+ " HAVING status_match(froot)\n"
2599
+ " ORDER BY 2 DESC\n"
2600
+ " LIMIT %d OFFSET %d\n"
2601
+ ")\n"
2602
+ "SELECT\n"
2603
+ " julianday('now') - thread.endtime,\n" /* 0 */
2604
+ " thread.endtime - "
2605
+ "(SELECT fmtime FROM forumpost WHERE fpid=root),\n" /* 1 */
2606
+ " (SELECT sum(fprev IS NULL) FROM forumpost"
2607
+ " WHERE froot=root),\n" /* 2 */
2608
+ " blob.uuid,\n" /* 3 */
2609
+ " substr(event.comment,instr(event.comment,':')+1),\n" /* 4 */
2610
+ " thread.lastrid,\n" /* 5 */
2611
+ " thread.root\n" /* 6 */
2612
+ " FROM thread, blob, event\n"
2613
+ " WHERE blob.rid=thread.lastrid\n"
2614
+ " AND event.objid=thread.lastrid\n"
21972615
" ORDER BY 1;",
2198
- g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
21992616
g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
22002617
iLimit+1, iOfst
22012618
);
22022619
while( db_step(&q)==SQLITE_ROW ){
2203
- char *zAge = human_readable_age(db_column_double(&q,0));
2204
- int nMsg = db_column_int(&q, 2);
2205
- const char *zUuid = db_column_text(&q, 3);
2206
- const char *zTitle = db_column_text(&q, 4);
2620
+ char *zAge;
2621
+ int nMsg;
2622
+ const char *zUuid;
2623
+ const char *zTitle;
2624
+ const char *zStatus;
2625
+ const char *zStatusLbl;
2626
+ const int bShowStatus = bHasStatus && !zStatusFilter;
2627
+ const int nCols = bShowStatus ? 4 : 3;
2628
+
2629
+ if( qStat.pStmt ){
2630
+ /* Determine the status value for this row */
2631
+ db_reset(&qStat);
2632
+ db_bind_int(&qStat, ":rid", db_column_int(&q,6));
2633
+ if( db_step(&qStat)==SQLITE_ROW ){
2634
+ zStatus = db_column_text(&qStat, 0);
2635
+ zStatusLbl = db_column_text(&qStat, 1);
2636
+ }else{
2637
+ zStatus = pFstat->aStatus[0].zValue;
2638
+ zStatusLbl = pFstat->aStatus[0].zLabel;
2639
+ }
2640
+ }else{
2641
+ zStatus = zStatusLbl = NULL;
2642
+ }
2643
+ zAge = human_readable_age(db_column_double(&q,0));
2644
+ nMsg = db_column_int(&q, 2);
2645
+ zUuid = db_column_text(&q, 3);
2646
+ zTitle = db_column_text(&q, 4);
22072647
if( iCnt==0 ){
2648
+ char *zTail = bFilter ? mprintf(" with status=%Q", zStatusFilter): 0;
22082649
if( iOfst>0 ){
2209
- @ <h1>Threads at least %s(zAge) old</h1>
2650
+ @ <h1>Threads at least %s(zAge) old%h(zTail ? zTail : "")</h1>
22102651
}else{
2211
- @ <h1>Most recent threads</h1>
2652
+ @ <h1>Most recent threads%h(zTail ? zTail : "")</h1>
22122653
}
2654
+ fossil_free(zTail);
22132655
@ <div class='forumPosts fileage'><table width="100%%">
22142656
if( iOfst>0 ){
22152657
if( iOfst>iLimit ){
2216
- @ <tr><td colspan="3">\
2217
- @ %z(href("%R/forum?x=%d&n=%d",iOfst-iLimit,iLimit))\
2218
- @ &uarr; Newer...</a></td></tr>
2658
+ @ <tr><td colspan="%d(nCols)">\
2659
+ @ <a href='%R/forum?x=%d(iOfst-iLimit)&n=%d(iLimit) \
2660
+ if( bFilter ){
2661
+ @ &status=%T(zStatusFilter)\
2662
+ }
2663
+ @ '>&uarr; Newer...</a></td></tr>
22192664
}else{
2220
- @ <tr><td colspan="3">%z(href("%R/forum?n=%d",iLimit))\
2221
- @ &uarr; Newer...</a></td></tr>
2665
+ @ <tr><td colspan="%d(nCols)">\
2666
+ @ <a href='%R/forum?n=%d(iLimit)\
2667
+ if( bFilter ){
2668
+ @ &status=%T(zStatusFilter) \
2669
+ }
2670
+ @ '>&uarr; Newer...</a></td></tr>
22222671
}
22232672
}
22242673
}
22252674
iCnt++;
22262675
if( iCnt>iLimit ){
2227
- @ <tr><td colspan="3">\
2228
- @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2229
- @ &darr; Older...</a></td></tr>
2676
+ @ <tr><td colspan="%d(nCols)">\
2677
+ @ <a href='%R/forum?x=%d(iOfst+iLimit)&n=%d(iLimit) \
2678
+ if( bFilter ){
2679
+ @ &status=%T(zStatusFilter)\
2680
+ }
2681
+ @ '>&darr; Older...</a></td></tr>
22302682
fossil_free(zAge);
22312683
break;
22322684
}
2233
- @ <tr><td>%h(zAge) ago</td>
2234
- @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2235
- @ <td>\
2685
+ @ <tr \
2686
+ if( bHasStatus ){
2687
+ @ data-status="%h(zStatus)"\
2688
+ }
2689
+ @ ><td>%h(zAge) ago</td>
2690
+ @ <td class='subject'>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a>\
2691
+ @ </td><td>\
22362692
if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
22372693
@ <span class="modpending">\
22382694
@ Awaiting Moderator Approval</span><br>
22392695
}
22402696
if( nMsg<2 ){
2241
- @ no replies</td>
2697
+ @ no replies\
22422698
}else{
22432699
char *zDuration = human_readable_age(db_column_double(&q,1));
2244
- @ %d(nMsg) posts spanning %h(zDuration)</td>
2700
+ @ %d(nMsg) posts spanning %h(zDuration)\
22452701
fossil_free(zDuration);
22462702
}
2247
- @ </tr>
2703
+ @ </td>\
2704
+ if( bShowStatus ){
2705
+ @ <td class='status'>%h(zStatusLbl)</td>\
2706
+ }
2707
+ if( qStat.pStmt ){
2708
+ db_reset(&qStat);
2709
+ }
2710
+ @</tr>
22482711
fossil_free(zAge);
22492712
}
22502713
db_finalize(&q);
2714
+ if( qStat.pStmt ) db_finalize(&qStat);
2715
+ sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0,0,0,0);
22512716
}
22522717
if( iCnt>0 ){
22532718
@ </table></div>
22542719
}else{
22552720
@ <h1>No forum posts found</h1>
22562721
}
2722
+ if( bHasStatus ){
2723
+ /* We need a JS-side kludge to avoid passing on the x=N
2724
+ ** URL arg when the status selection list is activated. */
2725
+ forum_emit_js();
2726
+ }
22572727
style_finish_page();
22582728
}
22592729
--- src/forum.c
+++ src/forum.c
@@ -61,12 +61,136 @@
61 ForumPost *pDisplay; /* Entries in display order */
62 ForumPost *pTail; /* Last on the display list */
63 int mxIndent; /* Maximum indentation level */
64 int nArtifact; /* Number of forum artifacts in this thread */
65 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66 #endif /* INTERFACE */
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68 /*
69 ** Return true if the forum post with the given rid has been
70 ** subsequently edited.
71 */
72 int forum_rid_has_been_edited(int rid){
@@ -124,14 +248,24 @@
124 ** matches the event.(euser,user) field for a formpost entry with the
125 ** matching RID. Returns false if no match is found. If zUserName is
126 ** 0 then login_name() is used.
127 */
128 int forumpost_is_owner(int rid, const char *zUserName){
129 return db_int(0, "SELECT 1 FROM event "
130 "WHERE type='f' AND objid=%d "
131 "AND coalesce(euser,user)=%Q",
132 rid, zUserName ? zUserName : login_name());
 
 
 
 
 
 
 
 
 
 
133 }
134
135 /*
136 ** Returns true if p, or any parent of p, has a non-zero iClosed
137 ** value. Returns 0 if !p. For an edited chain of post, the tag is
@@ -174,12 +308,12 @@
174 ** - 0 if no matching tag is found.
175 **
176 ** - The tagxref.rowid of the tagxref entry for the closure if rid is
177 ** the forum post to which the closure applies.
178 **
179 ** - (-tagxref.rowid) if the given rid inherits a "closed" tag from an
180 ** IRT forum post.
181 */
182 static int forum_rid_is_tagged(int rid, const char *zTagName, int bCheckIrt){
183 static Stmt qIrt = empty_Stmt_m;
184 int rc = 0, i = 0;
185 /* TODO: this can probably be turned into a CTE by someone with
@@ -266,25 +400,27 @@
266 ** provide consistent behavior, it always acts on the first version of
267 ** the given forum post, walking the forumpost.fprev values to find
268 ** the head of the chain.
269 **
270 ** If addTag is true then a propagating tag is added, except as noted
271 ** below, with the given optional zReason string as the tag's
272 ** value. If addTag is false then any matching active tag on frid is
273 ** cancelled, except as noted below. zReason is ignored if it is NULL
274 ** or starts with a NUL byte, or if addTag is false.
275 **
276 ** This function only adds a tag if forum_rid_is_tagged() indicates
277 ** that frid's head is not tagged. If a parent post is already tagged,
278 ** no tag is added. Similarly, it will only remove a tag from a post
279 ** which has its own tag, and will not remove an inherited one from a
280 ** parent post.
281 **
282 ** If addTag is true and frid is already tagged (directly or
283 ** inherited), this is a no-op. Likewise, if addTag is false and frid
284 ** itself is not tagged (not accounting for an inherited closed tag),
285 ** this is a no-op.
 
 
286 **
287 ** Returns true if it actually creates a new tag, else false. Fails
288 ** fatally on error.
289 **
290 ** If it returns true then state from previously-loaded posts may be
@@ -307,57 +443,62 @@
307 ** - The applied tag is propagating so so that "closed" tags can
308 ** account for how edits of posts are handled. This differs from
309 ** closure of a branch, where a non-propagating tag is used.
310 */
311 static int forumpost_tag(int frid, const char *zTagName, int addTag,
312 const char *zReason){
313 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
314 Blob cksum = BLOB_INITIALIZER; /* Z-card */
315 int iTagged; /* true if frid is already tagged */
316 int trid; /* RID of new control artifact */
317 char *zUuid; /* UUID of head version of post */
318
319 db_begin_transaction();
320 frid = forumpost_head_rid(frid);
321 iTagged = forum_rid_is_tagged(frid, "closed", 1);
322 if( (iTagged && addTag
323 /* Already tagged, noting that in the case of (addTag<0) it may
324 ** actually be a parent which is tagged. */)
325 || (iTagged<=0 && !addTag
326 /* This entry is not tagged, but a parent post may be. */) ){
327 db_end_transaction(0);
328 return 0;
329 }
330 if( addTag==0 || (zReason && !zReason[0]) ){
331 zReason = 0;
 
 
 
 
 
 
 
 
 
 
 
 
332 }
333 zUuid = rid_to_uuid(frid);
334 blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" ));
335 blob_appendf(&artifact, "T %c%s %s%s%F\n",
336 addTag ? '*' : '-', zTagName,
337 zUuid, zReason ? " " : "", zReason ? zReason : "");
338 blob_appendf(&artifact, "U %F\n", login_name());
339 md5sum_blob(&artifact, &cksum);
340 blob_appendf(&artifact, "Z %b\n", &cksum);
341 blob_reset(&cksum);
342 trid = content_put_ex(&artifact, 0, 0, 0, 0);
343 if( trid==0 ){
344 fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
345 }
346 if( manifest_crosslink(trid, &artifact,
347 MC_NONE /*MC_PERMIT_HOOKS?*/)==0 ){
348 fossil_fatal("%s", g.zErrMsg);
349 }
350 assert( blob_is_reset(&artifact) );
351 db_add_unsent(trid);
352 admin_log("Tag forum post %S with %c%s",
353 zUuid, addTag ? '*' : '-', zTagName);
354 fossil_free(zUuid);
355 /* Potential TODO: if (iClosed>0) then we could find the initial tag
356 ** artifact and content_deltify(thatRid,&trid,1,0). Given the tiny
357 ** size of these artifacts, however, that would save little space,
358 ** if any. */
359 db_end_transaction(0);
360 return 1;
361 }
362
363 /*
@@ -722,10 +863,71 @@
722 }
723 @ </table>
724 db_finalize(&q);
725 style_finish_page();
726 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
728 /*
729 ** Render a forum post for display
730 */
731 void forum_render(
@@ -863,21 +1065,24 @@
863 static void forum_render_attachment_list2(ForumPost *p){
864 if( p->pEditHead ) p = p->pEditHead;
865 forum_render_attachment_list(p->zUuid);
866 }
867
 
 
 
 
 
 
868 /*
869 ** Display a single post in a forum thread.
870 */
871 static void forum_display_post(
872 ForumThread *pThread, /* The thread that this post is a member of */
873 ForumPost *p, /* Forum post to display */
874 int iIndentScale, /* Indent scale factor */
875 int bRaw, /* True to omit the border */
876 int bUnf, /* True to leave the post unformatted */
877 int bHist, /* True if showing edit history */
878 int bSelect, /* True if this is the selected post */
879 char *zQuery /* Common query string */
880 ){
881 char *zPosterName; /* Name of user who originally made this post */
882 char *zEditorName; /* Name of user who provided the current edit */
883 char *zDate; /* The time/date string */
@@ -886,10 +1091,14 @@
886 Manifest *pManifest; /* Manifest comprising the current post */
887 int bPrivate; /* True for posts awaiting moderation */
888 int bSameUser; /* True if author is also the reader */
889 int iIndent; /* Indent level */
890 int iClosed; /* True if (sub)thread is closed */
 
 
 
 
891 const char *zMimetype;/* Formatting MIME type */
892
893 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
894 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
895 if( !pManifest ) return;
@@ -990,11 +1199,11 @@
990 /* Provide a link to the raw source code. */
991 if( !bUnf ){
992 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
993 }
994 @ </h3>
995 }
996
997 /* Check if this post is approved, also if it's by the current user. */
998 bPrivate = content_is_private(p->fpid);
999 bSameUser = login_is_individual()
1000 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
@@ -1058,14 +1267,16 @@
1058 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1059 if( forumpost_may_close() && iClosed>=0 ){
1060 @ <form method="post" \
1061 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1062 login_insert_csrf_secret();
1063 @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1064 if( moderation_pending(p->fpid)==0 ){
1065 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1066 @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
 
 
1067 }
1068 @ </form>
1069 }
1070 if( g.perm.Admin ||
1071 (login_is_individual()
@@ -1082,12 +1293,15 @@
1082 @ </form>
1083 }
1084 }
1085 @ </div>
1086 }
 
 
 
1087 @ </div>
1088 }
1089
1090 /* Clean up. */
1091 manifest_destroy(pManifest);
1092 }
1093
@@ -1197,12 +1411,18 @@
1197 }
1198
1199 /* Display the appropriate subset of posts in sequence. */
1200 while( p ){
1201 /* Display the post. */
1202 forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1203 bUnf, bHist, p==pSelect, zQuery);
 
 
 
 
 
 
1204
1205 /* Advance to the next post in the thread. */
1206 if( mode==FD_CHRONO ){
1207 /* Chronological mode: display posts (optionally including edits) in their
1208 ** original commit order. */
@@ -1572,44 +1792,82 @@
1572 mimetype_option_menu(zMimetype, "mimetype");
1573 @ <div class="forum-editor-widget">
1574 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1575 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1576 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1577
1578 /*
1579 ** WEBPAGE: forumpost_close hidden
1580 ** WEBPAGE: forumpost_reopen hidden
1581 **
1582 ** fpid=X Hash of the post to be edited. REQUIRED
1583 ** reason=X Optional reason for closure.
1584 **
1585 ** Closes or re-opens the given forum post, within the bounds of the
1586 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1587 ** status of the given thread, it redirects to that post's thread
1588 ** view. Requires admin privileges.
1589 */
1590 void forum_page_close(void){
1591 const char *zFpid = PD("fpid","");
1592 const char *zReason = 0;
1593 int fClose;
1594 int fpid;
1595
1596 login_check_credentials();
1597 if( forumpost_may_close()==0 ){
1598 login_needed(g.anon.Admin);
1599 return;
1600 }
1601 cgi_csrf_verify();
1602 fpid = symbolic_name_to_rid(zFpid, "f");
1603 if( fpid<=0 ){
1604 webpage_error("Missing or invalid fpid query parameter");
1605 }
1606 fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1607 if( fClose ) zReason = PD("reason",0);
1608 forumpost_tag(fpid, "closed", fClose, zReason);
1609 cgi_redirectf("%R/forumpost/%S",zFpid);
1610 return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1611 }
1612
1613 /*
1614 ** WEBPAGE: forumnew
1615 ** WEBPAGE: forumedit
@@ -1788,11 +2046,13 @@
1788 fpid = symbolic_name_to_rid(zFpid, "f");
1789 if( fpid<=0 || (pPost = manifest_get(fpid, CFTYPE_FORUM, 0))==0 ){
1790 webpage_error("Missing or invalid fpid query parameter");
1791 }
1792 froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid);
1793 if( froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0 ){
 
 
1794 webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid);
1795 }
1796 if( P("cancel") ){
1797 cgi_redirectf("%R/forumpost/%S",zFpid);
1798 return;
@@ -1968,10 +2228,21 @@
1968 ** seems more appropriate for the particular usage.
1969 **
1970 ** SETTING: attachment-size-limit width=16
1971 ** The maximum number of bytes for an attachment. The default (or 0) is
1972 ** unlimited but a limit may be imposed by the web server or a proxy.
 
 
 
 
 
 
 
 
 
 
 
1973 */
1974
1975 /*
1976 ** WEBPAGE: setup_forum
1977 **
@@ -2098,10 +2369,104 @@
2098 @ </form>
2099 }
2100
2101 style_finish_page();
2102 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2103
2104 /*
2105 ** WEBPAGE: forummain
2106 ** WEBPAGE: forum
2107 **
@@ -2118,30 +2483,35 @@
2118 void forum_main_page(void){
2119 Stmt q;
2120 int iLimit = 0, iOfst, iCnt;
2121 int srchFlags;
2122 const int isSearch = P("s")!=0;
2123 char const *zLimit = 0;
 
 
 
 
 
2124
2125 login_check_credentials();
2126 srchFlags = search_restrict(SRCH_FORUM);
2127 if( !g.perm.RdForum ){
2128 login_needed(g.anon.RdForum);
2129 return;
2130 }
2131 cgi_check_for_malice();
 
 
 
 
2132 style_set_current_feature("forum");
2133 style_header("%s%s", db_get("forum-title","Forum"),
2134 isSearch ? " Search Results" : "");
2135 style_submenu_element("Timeline", "%R/timeline?ss=v&y=f&vfx");
2136 if( g.perm.WrForum ){
2137 style_submenu_element("New Thread","%R/forumnew");
2138 }else{
2139 /* Can't combine this with previous case using the ternary operator
2140 * because that causes an error yelling about "non-constant format"
2141 * with some compilers. I can't see it, since both expressions have
2142 * the same format, but I'm no C spec lawyer. */
2143 style_submenu_element("New Thread","%R/login");
2144 }
2145 if( g.perm.ModForum && moderation_needed() ){
2146 style_submenu_element("Moderation Requests", "%R/modreq");
2147 }
@@ -2164,95 +2534,195 @@
2164 cgi_replace_query_parameter("n", fossil_strdup("25"))
2165 /*for the sake of Max, below*/;
2166 iLimit = 25;
2167 }
2168 style_submenu_entry("n","Max:",4,0);
 
 
2169 iOfst = atoi(PD("x","0"));
2170 iCnt = 0;
 
 
 
 
 
 
 
2171 if( db_table_exists("repository","forumpost") ){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2172 db_prepare(&q,
2173 "WITH thread(age,duration,cnt,root,last) AS ("
2174 " SELECT"
2175 " julianday('now') - max(fmtime),"
2176 " max(fmtime) - min(fmtime),"
2177 " sum(fprev IS NULL),"
2178 " froot,"
2179 " (SELECT fpid FROM forumpost AS y"
2180 " WHERE y.froot=x.froot %s"
2181 " ORDER BY y.fmtime DESC LIMIT 1)"
2182 " FROM forumpost AS x"
2183 " WHERE %s"
2184 " GROUP BY froot"
2185 " ORDER BY 1 LIMIT %d OFFSET %d"
2186 ")"
2187 "SELECT"
2188 " thread.age," /* 0 */
2189 " thread.duration," /* 1 */
2190 " thread.cnt," /* 2 */
2191 " blob.uuid," /* 3 */
2192 " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2193 " thread.last" /* 5 */
2194 " FROM thread, blob, event"
2195 " WHERE blob.rid=thread.last"
2196 " AND event.objid=thread.last"
 
2197 " ORDER BY 1;",
2198 g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
2199 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2200 iLimit+1, iOfst
2201 );
2202 while( db_step(&q)==SQLITE_ROW ){
2203 char *zAge = human_readable_age(db_column_double(&q,0));
2204 int nMsg = db_column_int(&q, 2);
2205 const char *zUuid = db_column_text(&q, 3);
2206 const char *zTitle = db_column_text(&q, 4);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2207 if( iCnt==0 ){
 
2208 if( iOfst>0 ){
2209 @ <h1>Threads at least %s(zAge) old</h1>
2210 }else{
2211 @ <h1>Most recent threads</h1>
2212 }
 
2213 @ <div class='forumPosts fileage'><table width="100%%">
2214 if( iOfst>0 ){
2215 if( iOfst>iLimit ){
2216 @ <tr><td colspan="3">\
2217 @ %z(href("%R/forum?x=%d&n=%d",iOfst-iLimit,iLimit))\
2218 @ &uarr; Newer...</a></td></tr>
 
 
 
2219 }else{
2220 @ <tr><td colspan="3">%z(href("%R/forum?n=%d",iLimit))\
2221 @ &uarr; Newer...</a></td></tr>
 
 
 
 
2222 }
2223 }
2224 }
2225 iCnt++;
2226 if( iCnt>iLimit ){
2227 @ <tr><td colspan="3">\
2228 @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2229 @ &darr; Older...</a></td></tr>
 
 
 
2230 fossil_free(zAge);
2231 break;
2232 }
2233 @ <tr><td>%h(zAge) ago</td>
2234 @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2235 @ <td>\
 
 
 
 
2236 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2237 @ <span class="modpending">\
2238 @ Awaiting Moderator Approval</span><br>
2239 }
2240 if( nMsg<2 ){
2241 @ no replies</td>
2242 }else{
2243 char *zDuration = human_readable_age(db_column_double(&q,1));
2244 @ %d(nMsg) posts spanning %h(zDuration)</td>
2245 fossil_free(zDuration);
2246 }
2247 @ </tr>
 
 
 
 
 
 
 
2248 fossil_free(zAge);
2249 }
2250 db_finalize(&q);
 
 
2251 }
2252 if( iCnt>0 ){
2253 @ </table></div>
2254 }else{
2255 @ <h1>No forum posts found</h1>
2256 }
 
 
 
 
 
2257 style_finish_page();
2258 }
2259
--- src/forum.c
+++ src/forum.c
@@ -61,12 +61,136 @@
61 ForumPost *pDisplay; /* Entries in display order */
62 ForumPost *pTail; /* Last on the display list */
63 int mxIndent; /* Maximum indentation level */
64 int nArtifact; /* Number of forum artifacts in this thread */
65 };
66
67 /*
68 ** A single entry from the forum-statuses setting.
69 */
70 struct ForumStatus {
71 char *zLabel; /* Label for the UI */
72 char *zValue; /* status=X tag value */
73 char *zDescr; /* Brief description */
74 };
75
76 /*
77 ** A list of ForumStatus objects.
78 */
79 struct ForumStatusList {
80 struct ForumStatus *aStatus; /* List of statuses */
81 unsigned int n; /* Number of entries */
82 };
83
84 /*
85 ** Information passed into the status_match() SQL function
86 ** via the sqlite3_user_data() mechanism, and used by status_match()
87 ** to determine whether or not a particular forum thread should
88 ** be displayed.
89 */
90 struct ForumStatusMatch {
91 const ForumStatusList *pFses; /* Parsed forum-statuses setting */
92 int eStatusTag; /* tagid for the "status" property */
93 unsigned int iMatch; /* Match this status value */
94 };
95 #endif /* INTERFACE */
96
97
98 /*
99 ** Returns a high-level representation of the forum-statuses setting.
100 ** This is a singleton, cached across calls.
101 */
102 static const ForumStatusList * forum_statuses(void){
103 static ForumStatusList fses = {0,0};
104 static int once = 0;
105 while( !once ){
106 ++once;
107 /* Read `forum-statuses` setting and transform it into the
108 ** fses object.
109 **
110 ** Maybe: if it's empty, synthesize a length-1 list from
111 ** {value:"default",label:"Default",...}. It's expected that
112 ** usage may be slightly simplified if we always have a non-empty
113 ** list. A length-1 list is, for purposes of the UI, identical to
114 ** an empty one - status selection/filtering makes no sense if
115 ** there's only one choice. */
116 db_multi_exec(
117 "CREATE TEMP TABLE IF NOT EXISTS forumstatus("
118 " ord INTEGER PRIMARY KEY,"
119 " label, value, descr"
120 ");"
121 "DELETE FROM forumstatus;"
122 "INSERT INTO forumstatus(label,value,descr)"
123 " WITH setting(v) AS ("
124 " SELECT value v FROM config WHERE name='forum-statuses'"
125 " ),"
126 " room(r) AS ("
127 " SELECT e.value FROM setting s, jsonb_each(s.v) e"
128 " WHERE json_valid(s.v, 0x02)"
129 " )"
130 " SELECT r->>'label', r->>'value', r->>'description'"
131 " FROM room;"
132 );
133 fses.n = (unsigned)db_int(0, "SELECT count(*) FROM forumstatus");
134 if( fses.n ){
135 int i = 0;
136 Stmt q;
137 db_prepare(&q,"SELECT label, value, descr FROM forumstatus"
138 " ORDER BY ord");
139 fses.aStatus = fossil_malloc(sizeof(fses.aStatus[0]) * fses.n);
140 while( SQLITE_ROW==db_step(&q) ){
141 ForumStatus * fs = &fses.aStatus[i++];
142 fs->zLabel = fossil_strdup(db_column_text(&q, 0));
143 fs->zValue = fossil_strdup(db_column_text(&q, 1));
144 fs->zDescr = fossil_strdup(db_column_text(&q, 2));
145 }
146 db_finalize(&q);
147 }
148 }
149 return &fses;
150 }
151
152 /*
153 ** Search for a ForumStatus object by its tag value. If a match is
154 ** found, the corresponding object is returned. If no match is found
155 ** then (A) if bFirst is false then 0 is returned, else (B) the first
156 ** entry in the list is returned, noting that the list may be empty,
157 ** in which case 0 is returned.
158 */
159 const ForumStatus * forum_status_by_value(const char *z, int bFirst){
160 const ForumStatusList * const fses = forum_statuses();
161 const ForumStatus * fs0 = 0;
162 unsigned int i;
163 if( !fses->n ) return 0;
164 for( i = 0; i < fses->n; ++i ){
165 const ForumStatus * fs = &fses->aStatus[i];
166 if( 0==fossil_strcmp(z, fs->zValue) ){
167 return fs;
168 }else if( !fs0 ){
169 fs0 = fs;
170 }
171 }
172 return bFirst ? fs0 : 0;
173 }
174
175 /*
176 ** COMMAND: test-forum-statuses
177 */
178 void test_forum_statuses_cmd(void){
179 const ForumStatusList * fses;
180 unsigned i;
181 db_find_and_open_repository(0,0);
182 fses = forum_statuses();
183 for(i = 0; i < fses->n; ++i ){
184 const ForumStatus * fs = &fses->aStatus[i];
185 fossil_print("Status: %!j %!j %!j\n",
186 fs->zValue, fs->zLabel, fs->zDescr);
187 assert( fs==forum_status_by_value(fs->zValue, 0) );
188 }
189 fossil_print("Total statuses: %u\n", i);
190 }
191
192 /*
193 ** Return true if the forum post with the given rid has been
194 ** subsequently edited.
195 */
196 int forum_rid_has_been_edited(int rid){
@@ -124,14 +248,24 @@
248 ** matches the event.(euser,user) field for a formpost entry with the
249 ** matching RID. Returns false if no match is found. If zUserName is
250 ** 0 then login_name() is used.
251 */
252 int forumpost_is_owner(int rid, const char *zUserName){
253 static Stmt q;
254 int rc;
255 if( !q.pStmt ){
256 db_static_prepare(
257 &q, "SELECT 1 FROM event"
258 " WHERE type='f' AND objid=$rid"
259 " AND coalesce(euser,user)=$user"
260 );
261 }
262 db_bind_int(&q, "$rid", rid);
263 db_bind_text(&q, "$user", zUserName ? zUserName : login_name());
264 rc = SQLITE_ROW==db_step(&q);
265 db_reset(&q);
266 return rc;
267 }
268
269 /*
270 ** Returns true if p, or any parent of p, has a non-zero iClosed
271 ** value. Returns 0 if !p. For an edited chain of post, the tag is
@@ -174,12 +308,12 @@
308 ** - 0 if no matching tag is found.
309 **
310 ** - The tagxref.rowid of the tagxref entry for the closure if rid is
311 ** the forum post to which the closure applies.
312 **
313 ** - (-tagxref.rowid) if the given rid inherits the tag from an IRT
314 ** forum post.
315 */
316 static int forum_rid_is_tagged(int rid, const char *zTagName, int bCheckIrt){
317 static Stmt qIrt = empty_Stmt_m;
318 int rc = 0, i = 0;
319 /* TODO: this can probably be turned into a CTE by someone with
@@ -266,25 +400,27 @@
400 ** provide consistent behavior, it always acts on the first version of
401 ** the given forum post, walking the forumpost.fprev values to find
402 ** the head of the chain.
403 **
404 ** If addTag is true then a propagating tag is added, except as noted
405 ** below, with the given optional zValue string as the tag's
406 ** value. If addTag is false then any matching active tag on frid is
407 ** cancelled, except as noted below. zValue is ignored if it is NULL
408 ** or starts with a NUL byte, or if addTag is false.
409 **
410 ** This function only adds a tag if forum_rid_is_tagged() indicates
411 ** that frid's head is not tagged. If a parent post is already tagged,
412 ** no tag is added. Similarly, it will only remove a tag from a post
413 ** which has its own tag, and will not remove an inherited one from a
414 ** parent post.
415 **
416 ** If addTag is true and frid is already tagged, this is a
417 ** no-op. Likewise, if addTag is false and frid is not tagged
418 ** (not accounting for an inherited closed tag), this is a no-op.
419 **
420 ** If bCheckIrt is true then the forum post IRT hierarchy is searched
421 ** for the tag, otherwise only the given RID is checked.
422 **
423 ** Returns true if it actually creates a new tag, else false. Fails
424 ** fatally on error.
425 **
426 ** If it returns true then state from previously-loaded posts may be
@@ -307,57 +443,62 @@
443 ** - The applied tag is propagating so so that "closed" tags can
444 ** account for how edits of posts are handled. This differs from
445 ** closure of a branch, where a non-propagating tag is used.
446 */
447 static int forumpost_tag(int frid, const char *zTagName, int addTag,
448 const char *zValue){
449 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
450 Blob cksum = BLOB_INITIALIZER; /* Z-card */
451 int iTagged; /* true if frid is already tagged */
452 int trid; /* RID of new control artifact */
453 char *zUuid; /* UUID of head version of post */
454
455 db_begin_transaction();
456 frid = forumpost_head_rid(frid);
457 iTagged = forum_rid_is_tagged(frid, zTagName, 0);
458 if( !addTag && !iTagged ){
459 /* Nothing to do. We never tag an IRT-inherited post via this
460 ** function. */
 
 
461 db_end_transaction(0);
462 return 0;
463 }
464 if( !addTag || (zValue && !zValue[0]) ){
465 zValue = 0;
466 }
467 if( addTag && iTagged ){
468 char *zOld = 0;
469 int cmp;
470 rid_has_tag2(iTagged, zTagName, &zOld);
471 cmp = fossil_strcmp(zOld, zValue);
472 fossil_free(zOld);
473 if( 0==cmp ){
474 /* Same value - leave it as is. */
475 db_end_transaction(0);
476 return 0;
477 }
478 }
479 zUuid = rid_to_uuid(frid);
480 blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" ));
481 blob_appendf(&artifact, "T %c%s %s%s%F\n",
482 addTag ? '*' : '-', zTagName,
483 zUuid, zValue ? " " : "", zValue ? zValue : "");
484 blob_appendf(&artifact, "U %F\n", login_name());
485 md5sum_blob(&artifact, &cksum);
486 blob_appendf(&artifact, "Z %b\n", &cksum);
487 blob_reset(&cksum);
488 trid = content_put_ex(&artifact, 0, 0, 0, 0);
489 if( trid==0 ){
490 fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
491 }
492 if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
 
493 fossil_fatal("%s", g.zErrMsg);
494 }
495 assert( blob_is_reset(&artifact) );
496 db_add_unsent(trid);
497 admin_log("Tag forum post %S with %c%s",
498 zUuid, addTag ? '*' : '-', zTagName);
499 fossil_free(zUuid);
 
 
 
 
500 db_end_transaction(0);
501 return 1;
502 }
503
504 /*
@@ -722,10 +863,71 @@
863 }
864 @ </table>
865 db_finalize(&q);
866 style_finish_page();
867 }
868
869 /*
870 ** Returns true if the current user is authorized to set forum post
871 ** fpid's status.
872 */
873 static int forum_may_set_status(int fpid){
874 return g.perm.Admin
875 || g.perm.ModForum
876 || (login_is_individual()
877 && forumpost_is_owner(fpid, 0));
878 }
879
880 /*
881 ** If the current user is authorized to set fp's status then this
882 ** renders a mini-form for setting the status then redirecting back to
883 ** the post. Else it may emit a status label or no output.
884 */
885 static void forum_render_status_selection( const ForumPost *fp ){
886 const ForumStatusList * const fss = forum_statuses();
887 if( fss->n>1 ){
888 const ForumPost * pHead = fp->pEditHead ? fp->pEditHead : fp;
889 int i;
890 char * zCurrent = 0;
891 const ForumStatus * sCurrent = 0;
892 rid_has_tag2(pHead->fpid, "status", &zCurrent);
893 for( i = 0; i < fss->n; ++i ){
894 const ForumStatus * const fs = &fss->aStatus[i];
895 if( 0==fossil_strcmp(zCurrent, fs->zValue) ){
896 sCurrent = fs;
897 break;
898 }
899 }
900 if( !sCurrent ) sCurrent = &fss->aStatus[0];
901 assert( sCurrent );
902 @ <span class='forum-status-selection'>
903 if( forum_may_set_status(fp->fpid) ){
904 @ <form method="post" action='%R/forumpost_status'>
905 login_insert_csrf_secret();
906 @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
907 @ <select name='status' data-fpid='%s(fp->zUuid)'\
908 @ data-initial-value='%h(zCurrent ? zCurrent : "")'>
909 for( i = 0; i < fss->n; ++i ){
910 const ForumStatus * const fs = &fss->aStatus[i];
911 @ <option value='%h(fs->zValue)'\
912 @ %s(sCurrent==fs ? " selected" : "")>\
913 @ %h(fs->zLabel)</option>
914 }
915 @ </select>
916 @ <input type='button' class='submit action-status' disabled
917 @ value='Change' />
918 /* ^^^ This must be <input>, not <button>, or else tapping it
919 ** will unconditionally submit. */
920 @ </form>
921 /* Form is activated in fossil.page.forumpost.js */
922 }else{
923 @ <button disabled>Status: %h(sCurrent->zLabel)</button>
924 }
925 @ </span>
926 fossil_free(zCurrent);
927 }
928 }
929
930 /*
931 ** Render a forum post for display
932 */
933 void forum_render(
@@ -863,21 +1065,24 @@
1065 static void forum_render_attachment_list2(ForumPost *p){
1066 if( p->pEditHead ) p = p->pEditHead;
1067 forum_render_attachment_list(p->zUuid);
1068 }
1069
1070 /* Flags for use with forum_display_post() */
1071 #define FDISPLAY_RAW 0x01 /* omit the border */
1072 #define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
1073 #define FDISPLAY_HISTORY 0x04 /* Showing edit history */
1074 #define FDISPLAY_SELECTED 0x08 /* This is the selected post */
1075
1076 /*
1077 ** Display a single post in a forum thread.
1078 */
1079 static void forum_display_post(
1080 ForumThread *pThread, /* The thread that this post is a member of */
1081 ForumPost *p, /* Forum post to display */
1082 int iIndentScale, /* Indent scale factor */
1083 int flags, /* From the FDISPLAY_... enum */
 
 
 
1084 char *zQuery /* Common query string */
1085 ){
1086 char *zPosterName; /* Name of user who originally made this post */
1087 char *zEditorName; /* Name of user who provided the current edit */
1088 char *zDate; /* The time/date string */
@@ -886,10 +1091,14 @@
1091 Manifest *pManifest; /* Manifest comprising the current post */
1092 int bPrivate; /* True for posts awaiting moderation */
1093 int bSameUser; /* True if author is also the reader */
1094 int iIndent; /* Indent level */
1095 int iClosed; /* True if (sub)thread is closed */
1096 const int bRaw = flags & FDISPLAY_RAW;
1097 const int bUnf = flags & FDISPLAY_UNFORMATTED;
1098 const int bHist = flags & FDISPLAY_HISTORY;
1099 const int bSelect = flags & FDISPLAY_SELECTED;
1100 const char *zMimetype;/* Formatting MIME type */
1101
1102 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
1103 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
1104 if( !pManifest ) return;
@@ -990,11 +1199,11 @@
1199 /* Provide a link to the raw source code. */
1200 if( !bUnf ){
1201 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
1202 }
1203 @ </h3>
1204 }/*!bRaw*/
1205
1206 /* Check if this post is approved, also if it's by the current user. */
1207 bPrivate = content_is_private(p->fpid);
1208 bSameUser = login_is_individual()
1209 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
@@ -1058,14 +1267,16 @@
1267 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1268 if( forumpost_may_close() && iClosed>=0 ){
1269 @ <form method="post" \
1270 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1271 login_insert_csrf_secret();
1272 @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1273 if( moderation_pending(p->fpid)==0 ){
1274 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1275 @ class='submit hidden \
1276 @ %s(iClosed ? "action-reopen" : "action-close")'/>
1277 /* ^^^ activated by fossil.page.forumpost.js */
1278 }
1279 @ </form>
1280 }
1281 if( g.perm.Admin ||
1282 (login_is_individual()
@@ -1082,12 +1293,15 @@
1293 @ </form>
1294 }
1295 }
1296 @ </div>
1297 }
1298 if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
1299 forum_render_status_selection(p);
1300 }
1301 @ </div>
1302 }/*!bRaw*/
1303
1304 /* Clean up. */
1305 manifest_destroy(pManifest);
1306 }
1307
@@ -1197,12 +1411,18 @@
1411 }
1412
1413 /* Display the appropriate subset of posts in sequence. */
1414 while( p ){
1415 /* Display the post. */
1416 forum_display_post(
1417 pThread, p, iIndentScale,
1418 (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1419 (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1420 (bHist ? FDISPLAY_HISTORY : 0) |
1421 (p==pSelect ? FDISPLAY_SELECTED : 0),
1422 zQuery
1423 );
1424
1425 /* Advance to the next post in the thread. */
1426 if( mode==FD_CHRONO ){
1427 /* Chronological mode: display posts (optionally including edits) in their
1428 ** original commit order. */
@@ -1572,44 +1792,82 @@
1792 mimetype_option_menu(zMimetype, "mimetype");
1793 @ <div class="forum-editor-widget">
1794 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1795 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1796 }
1797
1798 /*
1799 ** If PD("fpid") refers to a forum post, its rid is returned, else
1800 ** this function emits an error does not does return.
1801 */
1802 static int forum_validate_fpid_param(void){
1803 const char *zFpid = PD("fpid","");
1804 int fpid = symbolic_name_to_rid(zFpid, "f");
1805 if( fpid<=0 ){
1806 webpage_error("Missing or invalid fpid parameter.");
1807 }
1808 return fpid;
1809 }
1810
1811 /*
1812 ** Internal helper for /forumpost_XYZ internal pages which tag/untag
1813 ** posts.
1814 */
1815 static void forumpost_action_helper(const char *zTag, const char *zVal,
1816 int addTag, int validFpid){
1817 if( !cgi_csrf_safe(2) ){
1818 webpage_error("CSRF validation failed");
1819 }else{
1820 const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1821 forumpost_tag(fpid, zTag, addTag, zVal);
1822 cgi_redirectf("%R/forumpost/%S",P("fpid"));
1823 }
1824 }
1825
1826 /*
1827 ** WEBPAGE: forumpost_close hidden
1828 ** WEBPAGE: forumpost_reopen hidden
1829 **
1830 ** fpid=X Hash of the post to be edited. REQUIRED
 
1831 **
1832 ** Closes or re-opens the given forum post, within the bounds of the
1833 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1834 ** status of the given thread, it redirects to that post's thread
1835 ** view. Requires admin privileges.
1836 */
1837 void forum_page_close(void){
 
 
 
 
 
1838 login_check_credentials();
1839 if( forumpost_may_close()==0 ){
1840 login_needed(g.anon.Admin);
1841 }else{
1842 const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1843 forumpost_action_helper("closed", 0, bIsAdd, 0);
1844 }
1845 }
1846
1847 /*
1848 ** WEBPAGE: forumpost_status hidden
1849 **
1850 ** fpid=X Hash of the post to be edited. REQUIRED
1851 ** status=Y New status value. REQUIRED
1852 **
1853 ** Updates the current status=Y tag on the first version of
1854 ** the forum post X. Requires forum_may_set_status() permissions.
1855 */
1856 void forum_page_status(void){
1857 int fpid;
1858 login_check_credentials();
1859 fpid = forum_validate_fpid_param();
1860 if(forum_may_set_status(fpid)){
1861 const char *zStatus = PD("status",0);
1862 if( !zStatus || !zStatus[0] ){
1863 webpage_error("Missing required status.");
1864 }
1865 forumpost_action_helper("status", zStatus, 1, fpid);
1866 }else{
1867 webpage_error("You lack permissions to change this post's status.");
1868 }
1869 }
1870
1871 /*
1872 ** WEBPAGE: forumnew
1873 ** WEBPAGE: forumedit
@@ -1788,11 +2046,13 @@
2046 fpid = symbolic_name_to_rid(zFpid, "f");
2047 if( fpid<=0 || (pPost = manifest_get(fpid, CFTYPE_FORUM, 0))==0 ){
2048 webpage_error("Missing or invalid fpid query parameter");
2049 }
2050 froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid);
2051 if( (froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0)
2052 && P("reject")==0
2053 ){
2054 webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid);
2055 }
2056 if( P("cancel") ){
2057 cgi_redirectf("%R/forumpost/%S",zFpid);
2058 return;
@@ -1968,10 +2228,21 @@
2228 ** seems more appropriate for the particular usage.
2229 **
2230 ** SETTING: attachment-size-limit width=16
2231 ** The maximum number of bytes for an attachment. The default (or 0) is
2232 ** unlimited but a limit may be imposed by the web server or a proxy.
2233 **
2234 ** SETTING: forum-statuses width=40 block-text
2235 ** This JSON5-formatted value defines an array of objects describing
2236 ** the available statuses of forum posts. Each entry of the array must
2237 ** be an object in the form {label:"X",value:"Y",description:"Z"}.
2238 ** The label is used in the UI and value becomes the value of the
2239 ** "status" tag on forum posts. Any forum post which has a status
2240 ** value which does not appear in this list is treated as if it had
2241 ** the first value from this list. If this setting is empty, is
2242 ** ill-formed JSON, or has only a single entry then the forum will
2243 ** lack the capability of setting and filtering by status.
2244 */
2245
2246 /*
2247 ** WEBPAGE: setup_forum
2248 **
@@ -2098,10 +2369,104 @@
2369 @ </form>
2370 }
2371
2372 style_finish_page();
2373 }
2374
2375 /*
2376 ** If the forum-statuses setting is active and has 2 or more entries,
2377 ** this adds a submenu for selecting the status filter, else it emits
2378 ** nothing.
2379 */
2380 static void forum_status_submenu(void){
2381 const ForumStatusList * const fss = forum_statuses();
2382 static int i = 0;
2383 static const char **az;
2384 if( i==0 && fss->n>1 ){
2385 unsigned j;
2386 az = fossil_malloc(sizeof(az[0]) * ((1 + fss->n) * 2));
2387 az[i++] = "*";
2388 az[i++] = "Any status";
2389 for( j = 0; j < fss->n; ++j ){
2390 const ForumStatus * fs = &fss->aStatus[j];
2391 /* Potential TODO: skip any entries for which there are no
2392 ** forum posts with a status=${fs->zValue} tag. */
2393 az[i++] = fs->zValue;
2394 az[i++] = fs->zLabel;
2395 }
2396 //assert( i==(1+fss->n)*2 );
2397 }
2398 if( i ){
2399 cookie_link_parameter("status","forumStatus","*");
2400 style_submenu_multichoice("status", i/2, az, 0);
2401 }
2402 }
2403
2404 /*
2405 ** Transient SQL Function: status_match(FROOT)
2406 **
2407 ** Return true if the forum thread identified by FROOT should be included
2408 ** in a list of threads. Used to implement the status=NAME query parameter
2409 ** on /forum.
2410 **
2411 ** The result of this routine depends on the content of the
2412 ** ForumStatusMatch *pMData object that is available via sqlite3_user_data().
2413 **
2414 ** * If pMData==NULL, always return true. This means that no
2415 ** filtering of threads is being done. This is the common case.
2416 **
2417 ** * If FROOT contains a status property value that matches
2418 ** pMData->iMatch, return true.
2419 **
2420 ** * if pMData->iMatch==0 (meaning we want to match the default
2421 ** status value) and if the FROOT thread contains a status that
2422 ** is not on the list of statuses or if FROOT has no statue
2423 ** property at all, then return true. In other words, a forum
2424 ** thread with no status property or an unknown status property
2425 ** is treated as if it had the default status.
2426 **
2427 ** * Otherwise, return false.
2428 */
2429 static void forum_status_match(
2430 sqlite3_context *context,
2431 int argc,
2432 sqlite3_value **argv
2433 ){
2434 static Stmt q;
2435 ForumStatusMatch *pMData = sqlite3_user_data(context);
2436 int i;
2437
2438 if( pMData==0 ){
2439 sqlite3_result_int(context, 1);
2440 return;
2441 }
2442 db_static_prepare(&q,
2443 "SELECT value FROM tagxref\n"
2444 " WHERE tagid=%d\n"
2445 " AND tagtype>=1\n"
2446 " AND rid=:rid\n"
2447 " ORDER BY mtime DESC LIMIT 1",
2448 pMData->eStatusTag
2449 );
2450 db_bind_int(&q, ":rid", sqlite3_value_int(argv[0]));
2451 if( db_step(&q)==SQLITE_ROW ){
2452 const char *zValue = (const char*)db_column_text(&q,0);
2453 const ForumStatusList *pFses = pMData->pFses;
2454 if( zValue==0 ){
2455 i = 0;
2456 }else{
2457 for(i=0; i<pFses->n; i++){
2458 if( fossil_strcmp(pFses->aStatus[i].zValue,zValue)==0 ) break;
2459 }
2460 }
2461 if( i>=pMData->pFses->n ) i = 0;
2462 }else{
2463 i = 0;
2464 }
2465 db_reset(&q);
2466 sqlite3_result_int(context, i==pMData->iMatch);
2467 }
2468
2469 /*
2470 ** WEBPAGE: forummain
2471 ** WEBPAGE: forum
2472 **
@@ -2118,30 +2483,35 @@
2483 void forum_main_page(void){
2484 Stmt q;
2485 int iLimit = 0, iOfst, iCnt;
2486 int srchFlags;
2487 const int isSearch = P("s")!=0;
2488 const char *zStatusFilter;
2489 char const *zLimit = 0; /* Value of the n= query parameter */
2490 int eStatusTag = 0; /* tagid for the "status" property */
2491 int bHasStatus = 0; /* True if forum-statuses setting exists */
2492 int bFilter = 0; /* True if status=NAME query parameter */
2493 ForumStatusMatch sFSM; /* Aux data to status_match() SQL function */
2494
2495 login_check_credentials();
2496 srchFlags = search_restrict(SRCH_FORUM);
2497 if( !g.perm.RdForum ){
2498 login_needed(g.anon.RdForum);
2499 return;
2500 }
2501 cgi_check_for_malice();
2502 eStatusTag = db_int(0, "SELECT tagid FROM tag WHERE tagname='status'");
2503 if( eStatusTag && forum_statuses()->n>1 ){
2504 bHasStatus = 1;
2505 }
2506 style_set_current_feature("forum");
2507 style_header("%s%s", db_get("forum-title","Forum"),
2508 isSearch ? " Search Results" : "");
2509 style_submenu_element("Timeline", "%R/timeline?ss=v&y=f&vfx");
2510 if( g.perm.WrForum ){
2511 style_submenu_element("New Thread","%R/forumnew");
2512 }else{
 
 
 
 
2513 style_submenu_element("New Thread","%R/login");
2514 }
2515 if( g.perm.ModForum && moderation_needed() ){
2516 style_submenu_element("Moderation Requests", "%R/modreq");
2517 }
@@ -2164,95 +2534,195 @@
2534 cgi_replace_query_parameter("n", fossil_strdup("25"))
2535 /*for the sake of Max, below*/;
2536 iLimit = 25;
2537 }
2538 style_submenu_entry("n","Max:",4,0);
2539 forum_status_submenu();
2540 zStatusFilter = P("status") /*must be after forum_status_submenu()!*/;
2541 iOfst = atoi(PD("x","0"));
2542 iCnt = 0;
2543 if( zStatusFilter ){
2544 if( zStatusFilter[0]==0 || 0==fossil_strcmp("*",zStatusFilter) ){
2545 zStatusFilter = 0;
2546 }else{
2547 bFilter = bHasStatus;
2548 }
2549 }
2550 if( db_table_exists("repository","forumpost") ){
2551 const ForumStatusList *pFstat = forum_statuses();
2552 Stmt qStat = empty_Stmt; /* Query to get status information */
2553 if( bHasStatus ){
2554 /* The qStat query runs once for each output row generate by the
2555 ** q query. It determines the value and label of the status for
2556 ** the row with froot=:rowid
2557 */
2558 db_prepare(&qStat,
2559 "SELECT tagxref.value, forumstatus.label\n"
2560 " FROM forumstatus, tagxref\n"
2561 " WHERE tagid=%d AND tagtype>=1\n"
2562 " AND forumstatus.value=tagxref.value\n"
2563 " AND rid=:rid\n"
2564 " ORDER BY mtime DESC",
2565 eStatusTag
2566 );
2567 }
2568
2569 /* Create the status_match() SQL function that will determine
2570 ** whether or not each thread in the "q" query below is eligible
2571 ** for display
2572 */
2573 if( bFilter ){
2574 sFSM.pFses = pFstat;
2575 sFSM.eStatusTag = eStatusTag;
2576 for(sFSM.iMatch=0; sFSM.iMatch<pFstat->n; sFSM.iMatch++){
2577 if( 0==fossil_strcmp(zStatusFilter,
2578 pFstat->aStatus[sFSM.iMatch].zValue) ){
2579 break;
2580 }
2581 }
2582 sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,(void*)&sFSM,
2583 forum_status_match, 0, 0);
2584 }else{
2585 sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0,
2586 forum_status_match, 0, 0);
2587 }
2588
2589 db_prepare(&q,
2590 "WITH thread(root,endtime,lastrid) AS (\n"
2591 " SELECT\n"
2592 " froot,\n"
2593 " max(fmtime),\n"
2594 " fpid\n"
2595 " FROM forumpost\n"
2596 " WHERE %s/*ModForum*/\n"
2597 " GROUP BY froot\n"
2598 " HAVING status_match(froot)\n"
2599 " ORDER BY 2 DESC\n"
2600 " LIMIT %d OFFSET %d\n"
2601 ")\n"
2602 "SELECT\n"
2603 " julianday('now') - thread.endtime,\n" /* 0 */
2604 " thread.endtime - "
2605 "(SELECT fmtime FROM forumpost WHERE fpid=root),\n" /* 1 */
2606 " (SELECT sum(fprev IS NULL) FROM forumpost"
2607 " WHERE froot=root),\n" /* 2 */
2608 " blob.uuid,\n" /* 3 */
2609 " substr(event.comment,instr(event.comment,':')+1),\n" /* 4 */
2610 " thread.lastrid,\n" /* 5 */
2611 " thread.root\n" /* 6 */
2612 " FROM thread, blob, event\n"
2613 " WHERE blob.rid=thread.lastrid\n"
2614 " AND event.objid=thread.lastrid\n"
2615 " ORDER BY 1;",
 
2616 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2617 iLimit+1, iOfst
2618 );
2619 while( db_step(&q)==SQLITE_ROW ){
2620 char *zAge;
2621 int nMsg;
2622 const char *zUuid;
2623 const char *zTitle;
2624 const char *zStatus;
2625 const char *zStatusLbl;
2626 const int bShowStatus = bHasStatus && !zStatusFilter;
2627 const int nCols = bShowStatus ? 4 : 3;
2628
2629 if( qStat.pStmt ){
2630 /* Determine the status value for this row */
2631 db_reset(&qStat);
2632 db_bind_int(&qStat, ":rid", db_column_int(&q,6));
2633 if( db_step(&qStat)==SQLITE_ROW ){
2634 zStatus = db_column_text(&qStat, 0);
2635 zStatusLbl = db_column_text(&qStat, 1);
2636 }else{
2637 zStatus = pFstat->aStatus[0].zValue;
2638 zStatusLbl = pFstat->aStatus[0].zLabel;
2639 }
2640 }else{
2641 zStatus = zStatusLbl = NULL;
2642 }
2643 zAge = human_readable_age(db_column_double(&q,0));
2644 nMsg = db_column_int(&q, 2);
2645 zUuid = db_column_text(&q, 3);
2646 zTitle = db_column_text(&q, 4);
2647 if( iCnt==0 ){
2648 char *zTail = bFilter ? mprintf(" with status=%Q", zStatusFilter): 0;
2649 if( iOfst>0 ){
2650 @ <h1>Threads at least %s(zAge) old%h(zTail ? zTail : "")</h1>
2651 }else{
2652 @ <h1>Most recent threads%h(zTail ? zTail : "")</h1>
2653 }
2654 fossil_free(zTail);
2655 @ <div class='forumPosts fileage'><table width="100%%">
2656 if( iOfst>0 ){
2657 if( iOfst>iLimit ){
2658 @ <tr><td colspan="%d(nCols)">\
2659 @ <a href='%R/forum?x=%d(iOfst-iLimit)&n=%d(iLimit) \
2660 if( bFilter ){
2661 @ &status=%T(zStatusFilter)\
2662 }
2663 @ '>&uarr; Newer...</a></td></tr>
2664 }else{
2665 @ <tr><td colspan="%d(nCols)">\
2666 @ <a href='%R/forum?n=%d(iLimit)\
2667 if( bFilter ){
2668 @ &status=%T(zStatusFilter) \
2669 }
2670 @ '>&uarr; Newer...</a></td></tr>
2671 }
2672 }
2673 }
2674 iCnt++;
2675 if( iCnt>iLimit ){
2676 @ <tr><td colspan="%d(nCols)">\
2677 @ <a href='%R/forum?x=%d(iOfst+iLimit)&n=%d(iLimit) \
2678 if( bFilter ){
2679 @ &status=%T(zStatusFilter)\
2680 }
2681 @ '>&darr; Older...</a></td></tr>
2682 fossil_free(zAge);
2683 break;
2684 }
2685 @ <tr \
2686 if( bHasStatus ){
2687 @ data-status="%h(zStatus)"\
2688 }
2689 @ ><td>%h(zAge) ago</td>
2690 @ <td class='subject'>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a>\
2691 @ </td><td>\
2692 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2693 @ <span class="modpending">\
2694 @ Awaiting Moderator Approval</span><br>
2695 }
2696 if( nMsg<2 ){
2697 @ no replies\
2698 }else{
2699 char *zDuration = human_readable_age(db_column_double(&q,1));
2700 @ %d(nMsg) posts spanning %h(zDuration)\
2701 fossil_free(zDuration);
2702 }
2703 @ </td>\
2704 if( bShowStatus ){
2705 @ <td class='status'>%h(zStatusLbl)</td>\
2706 }
2707 if( qStat.pStmt ){
2708 db_reset(&qStat);
2709 }
2710 @</tr>
2711 fossil_free(zAge);
2712 }
2713 db_finalize(&q);
2714 if( qStat.pStmt ) db_finalize(&qStat);
2715 sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0,0,0,0);
2716 }
2717 if( iCnt>0 ){
2718 @ </table></div>
2719 }else{
2720 @ <h1>No forum posts found</h1>
2721 }
2722 if( bHasStatus ){
2723 /* We need a JS-side kludge to avoid passing on the x=N
2724 ** URL arg when the status selection list is activated. */
2725 forum_emit_js();
2726 }
2727 style_finish_page();
2728 }
2729
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -94,42 +94,87 @@
9494
forumPostWrapper.appendChild(widget);
9595
}
9696
content.appendChild(rightTapZone);
9797
rightTapZone.addEventListener('click', widgetEventHandler, false);
9898
refillTapZone();
99
- })/*F.onPageLoad()*/;
99
+ })/*for-each div.forumTime|div.forumEdit*/;
100100
101101
if(F.pikchr){
102102
F.pikchr.addSrcView();
103103
}
104104
105
- /* Attempt to keep stray double-clicks from double-posting. */
106
- const formSubmitted = function(event){
107
- const form = event.target;
108
- if( form.dataset.submitted ){
109
- event.preventDefault();
110
- return;
111
- }
112
- form.dataset.submitted = '1';
113
- /** If the user is left waiting "a long time," disable the
114
- resubmit protection. If we don't do this and they tap the
115
- browser's cancel button while waiting, they'll be stuck with
116
- an unsubmittable form. */
117
- setTimeout(()=>{delete form.dataset.submitted}, 7000);
118
- return;
119
- };
120
- document.querySelectorAll("form").forEach(function(form){
121
- form.addEventListener('submit',formSubmitted);
122
- form
123
- .querySelectorAll("input.action-close, input.action-reopen")
124
- .forEach(function(e){
125
- F.confirmer(e, {
126
- confirmText: (e.classList.contains('action-reopen')
127
- ? "Confirm re-open"
128
- : "Confirm close"),
129
- onconfirm: ()=>form.submit()
130
- });
131
- });
132
- });
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
120
+ double-posting.
121
+ https://fossil-scm.org/forum/info/6bd02466533aa131 */
122
+ const formSubmitted = function(event){
123
+ const form = event.target;
124
+ if( form.dataset.submitted ){
125
+ event.preventDefault();
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');
142
+ F.confirmer(e, {
143
+ confirmText: (e.classList.contains('action-reopen')
144
+ ? "Confirm re-open"
145
+ : "Confirm close"),
146
+ onconfirm: ()=>form.submit()
147
+ });
148
+ });
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');
160
+ }
161
+ }else{
162
+ if(sel.selectedIndex===0){
163
+ btn.setAttribute('disabled','');
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
+ }
133178
134179
})/*F.onPageLoad callback*/;
135180
})(window.fossil);
136181
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -94,42 +94,87 @@
94 forumPostWrapper.appendChild(widget);
95 }
96 content.appendChild(rightTapZone);
97 rightTapZone.addEventListener('click', widgetEventHandler, false);
98 refillTapZone();
99 })/*F.onPageLoad()*/;
100
101 if(F.pikchr){
102 F.pikchr.addSrcView();
103 }
104
105 /* Attempt to keep stray double-clicks from double-posting. */
106 const formSubmitted = function(event){
107 const form = event.target;
108 if( form.dataset.submitted ){
109 event.preventDefault();
110 return;
111 }
112 form.dataset.submitted = '1';
113 /** If the user is left waiting "a long time," disable the
114 resubmit protection. If we don't do this and they tap the
115 browser's cancel button while waiting, they'll be stuck with
116 an unsubmittable form. */
117 setTimeout(()=>{delete form.dataset.submitted}, 7000);
118 return;
119 };
120 document.querySelectorAll("form").forEach(function(form){
121 form.addEventListener('submit',formSubmitted);
122 form
123 .querySelectorAll("input.action-close, input.action-reopen")
124 .forEach(function(e){
125 F.confirmer(e, {
126 confirmText: (e.classList.contains('action-reopen')
127 ? "Confirm re-open"
128 : "Confirm close"),
129 onconfirm: ()=>form.submit()
130 });
131 });
132 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
134 })/*F.onPageLoad callback*/;
135 })(window.fossil);
136
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -94,42 +94,87 @@
94 forumPostWrapper.appendChild(widget);
95 }
96 content.appendChild(rightTapZone);
97 rightTapZone.addEventListener('click', widgetEventHandler, false);
98 refillTapZone();
99 })/*for-each div.forumTime|div.forumEdit*/;
100
101 if(F.pikchr){
102 F.pikchr.addSrcView();
103 }
104
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
120 double-posting.
121 https://fossil-scm.org/forum/info/6bd02466533aa131 */
122 const formSubmitted = function(event){
123 const form = event.target;
124 if( form.dataset.submitted ){
125 event.preventDefault();
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');
142 F.confirmer(e, {
143 confirmText: (e.classList.contains('action-reopen')
144 ? "Confirm re-open"
145 : "Confirm close"),
146 onconfirm: ()=>form.submit()
147 });
148 });
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');
160 }
161 }else{
162 if(sel.selectedIndex===0){
163 btn.setAttribute('disabled','');
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
+35
--- src/tag.c
+++ src/tag.c
@@ -1003,10 +1003,45 @@
10031003
" AND tagxref.tagid=tag.tagid",
10041004
rid, tagId
10051005
);
10061006
}
10071007
1008
+
1009
+/*
1010
+** If the given blob.rid value has the given tag applied to it,
1011
+** returns true and sets *pOut to a copy of its value (or NULL if it
1012
+** has no value). Else returns false and sets *pOut to 0. A truthy
1013
+** value returned is the associated tag.tagid value.
1014
+**
1015
+** Ownership of *pOut is transfered to the caller, who must eventually
1016
+** fossil_free() it.
1017
+*/
1018
+int rid_has_tag2(int rid, const char *zTag, char **pOut){
1019
+ static Stmt q;
1020
+ int rc = 0;
1021
+ if( !q.pStmt ){
1022
+ db_prepare(
1023
+ &q, "SELECT t.tagid, x.value"
1024
+ " FROM tagxref x, tag t"
1025
+ " WHERE x.rid=:rid"
1026
+ " AND x.tagtype>0"
1027
+ " AND x.tagid=t.tagid"
1028
+ " AND t.tagname=:name"
1029
+ " ORDER BY mtime DESC"
1030
+ );
1031
+ }
1032
+ *pOut = 0;
1033
+ db_bind_int(&q, ":rid", rid);
1034
+ db_bind_text(&q, ":name", zTag);
1035
+ if( SQLITE_ROW==db_step(&q) ){
1036
+ rc = db_column_int(&q, 0);
1037
+ *pOut = fossil_strdup(db_column_text(&q, 1));
1038
+ }
1039
+ db_reset(&q);
1040
+ return rc;
1041
+}
1042
+
10081043
10091044
/*
10101045
** Returns tagxref.rowid if the given blob.rid has a tagxref.rid entry
10111046
** of an active (non-cancelled) tag matching the given rid and tag
10121047
** name string, else returns 0. This function does not distinguish
10131048
--- src/tag.c
+++ src/tag.c
@@ -1003,10 +1003,45 @@
1003 " AND tagxref.tagid=tag.tagid",
1004 rid, tagId
1005 );
1006 }
1007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
1009 /*
1010 ** Returns tagxref.rowid if the given blob.rid has a tagxref.rid entry
1011 ** of an active (non-cancelled) tag matching the given rid and tag
1012 ** name string, else returns 0. This function does not distinguish
1013
--- src/tag.c
+++ src/tag.c
@@ -1003,10 +1003,45 @@
1003 " AND tagxref.tagid=tag.tagid",
1004 rid, tagId
1005 );
1006 }
1007
1008
1009 /*
1010 ** If the given blob.rid value has the given tag applied to it,
1011 ** returns true and sets *pOut to a copy of its value (or NULL if it
1012 ** has no value). Else returns false and sets *pOut to 0. A truthy
1013 ** value returned is the associated tag.tagid value.
1014 **
1015 ** Ownership of *pOut is transfered to the caller, who must eventually
1016 ** fossil_free() it.
1017 */
1018 int rid_has_tag2(int rid, const char *zTag, char **pOut){
1019 static Stmt q;
1020 int rc = 0;
1021 if( !q.pStmt ){
1022 db_prepare(
1023 &q, "SELECT t.tagid, x.value"
1024 " FROM tagxref x, tag t"
1025 " WHERE x.rid=:rid"
1026 " AND x.tagtype>0"
1027 " AND x.tagid=t.tagid"
1028 " AND t.tagname=:name"
1029 " ORDER BY mtime DESC"
1030 );
1031 }
1032 *pOut = 0;
1033 db_bind_int(&q, ":rid", rid);
1034 db_bind_text(&q, ":name", zTag);
1035 if( SQLITE_ROW==db_step(&q) ){
1036 rc = db_column_int(&q, 0);
1037 *pOut = fossil_strdup(db_column_text(&q, 1));
1038 }
1039 db_reset(&q);
1040 return rc;
1041 }
1042
1043
1044 /*
1045 ** Returns tagxref.rowid if the given blob.rid has a tagxref.rid entry
1046 ** of an active (non-cancelled) tag matching the given rid and tag
1047 ** name string, else returns 0. This function does not distinguish
1048
--- www/forum.wiki
+++ www/forum.wiki
@@ -407,5 +407,73 @@
407407
not permitted, without appropriate permissions, to close their own
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.
412
+
413
+<h2 name="status">Setting Post Statuses</h2>
414
+
415
+The setting <tt>forum-statuses</tt> controls whether this feature is
416
+is enabled. If it is not set, is not valid JSON5, or has only a single
417
+entry, then status are not shown in the forum.
418
+
419
+<tt>forum-statuses</tt> is a JSON5-format array of objects in the
420
+following format:
421
+
422
+<verbatim>
423
+[
424
+ {label:"Open", value:"open", description:"Unresolved posts"},
425
+ {label:"Resolved", value:"resolved", description:"Resolved posts"}
426
+ ...
427
+]</verbatim>
428
+
429
+The list defines the legal statuses for forum posts and syncs with
430
+other project-level settings. All "label" and "value" members must be
431
+unique within the scope of that list.
432
+
433
+The "value" values must be legal for use as HTML "dataset" members so
434
+that they can be used for with CSS selectors to apply per-status
435
+styling.
436
+
437
+If the list is valid JSON5 and has two or more entries then the forum
438
+behavior changes in the following ways:
439
+
440
+ * Each TR element of the main <tt>/forum</tt> view table gets a
441
+ <tt>data-status="X"</tt> member, where "X" is the value part from
442
+ the associated status. This can be used to style the statuses
443
+ in the site skin using CSS selectors such as<br>
444
+ <tt>body.cpage-forum div.forumPosts tr&#91;data-status="open"]</tt><br>
445
+ Because there is no default status list, no predefined styles
446
+ are applied.
447
+ * The forum list shows the "label" value of the current status and
448
+ a selection list of available filters. It elides the status column
449
+ when filtering on a specific status.
450
+ * The root of each thread, when selected, gets a new UI control for
451
+ displaying or editing the status, depending on permissions.
452
+
453
+A list with only a single entry is treated as empty, the justification
454
+being that if there is only one option then the UI does not need to be
455
+cluttered with it.
456
+
457
+This setting is applied via a <tt>status</tt> tag on the first version
458
+of a legal post. That tag may be set to any value from the "values"
459
+members of the <tt>forum-statuses</tt> list.
460
+
461
+Status tag rules:
462
+
463
+ * <tt>status</tt> is only observed on the root of each thread. The UI
464
+ does nothing with that tag on responses.
465
+ * The first list entry is considered to be the default status. Any post
466
+ which has no <tt>status</tt> tag, or has a <tt>status</tt> tag value
467
+ which is not in that list, is assumed for most purposes to have the
468
+ value of the first entry in the list.
469
+ * The tag is applied only to the first version of any given post. The
470
+ UI ensures that the tag is applied to the proper post, even when
471
+ rendering a newer version.
472
+
473
+The UI will eventually offer the ability to filter the post list by
474
+status.
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".
412480
--- www/forum.wiki
+++ www/forum.wiki
@@ -407,5 +407,73 @@
407 not permitted, without appropriate permissions, to close their own
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
--- www/forum.wiki
+++ www/forum.wiki
@@ -407,5 +407,73 @@
407 not permitted, without appropriate permissions, to close their own
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 The setting <tt>forum-statuses</tt> controls whether this feature is
416 is enabled. If it is not set, is not valid JSON5, or has only a single
417 entry, then status are not shown in the forum.
418
419 <tt>forum-statuses</tt> is a JSON5-format array of objects in the
420 following format:
421
422 <verbatim>
423 [
424 {label:"Open", value:"open", description:"Unresolved posts"},
425 {label:"Resolved", value:"resolved", description:"Resolved posts"}
426 ...
427 ]</verbatim>
428
429 The list defines the legal statuses for forum posts and syncs with
430 other project-level settings. All "label" and "value" members must be
431 unique within the scope of that list.
432
433 The "value" values must be legal for use as HTML "dataset" members so
434 that they can be used for with CSS selectors to apply per-status
435 styling.
436
437 If the list is valid JSON5 and has two or more entries then the forum
438 behavior changes in the following ways:
439
440 * Each TR element of the main <tt>/forum</tt> view table gets a
441 <tt>data-status="X"</tt> member, where "X" is the value part from
442 the associated status. This can be used to style the statuses
443 in the site skin using CSS selectors such as<br>
444 <tt>body.cpage-forum div.forumPosts tr&#91;data-status="open"]</tt><br>
445 Because there is no default status list, no predefined styles
446 are applied.
447 * The forum list shows the "label" value of the current status and
448 a selection list of available filters. It elides the status column
449 when filtering on a specific status.
450 * The root of each thread, when selected, gets a new UI control for
451 displaying or editing the status, depending on permissions.
452
453 A list with only a single entry is treated as empty, the justification
454 being that if there is only one option then the UI does not need to be
455 cluttered with it.
456
457 This setting is applied via a <tt>status</tt> tag on the first version
458 of a legal post. That tag may be set to any value from the "values"
459 members of the <tt>forum-statuses</tt> list.
460
461 Status tag rules:
462
463 * <tt>status</tt> is only observed on the root of each thread. The UI
464 does nothing with that tag on responses.
465 * The first list entry is considered to be the default status. Any post
466 which has no <tt>status</tt> tag, or has a <tt>status</tt> tag value
467 which is not in that list, is assumed for most purposes to have the
468 value of the first entry in the list.
469 * The tag is applied only to the first version of any given post. The
470 UI ensures that the tag is applied to the proper post, even when
471 rendering a newer version.
472
473 The UI will eventually offer the ability to filter the post list by
474 status.
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

Keyboard Shortcuts

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