Index: src/attach.c
==================================================================
--- src/attach.c
+++ src/attach.c
@@ -244,11 +244,11 @@
"INSERT INTO modreq(objid,attachRid) VALUES(%d,%d);",
rid, attachRid
);
}else{
rid = content_put(pAttach);
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d);", rid);
+ db_add_unsent(rid);
db_multi_exec("INSERT OR IGNORE INTO unclustered VALUES(%d);", rid);
}
manifest_crosslink(rid, pAttach, MC_NONE);
}
Index: src/branch.c
==================================================================
--- src/branch.c
+++ src/branch.c
@@ -193,17 +193,17 @@
brid = content_put_ex(&branch, 0, 0, 0, isPrivate);
if( brid==0 ){
fossil_fatal("trouble committing manifest: %s", g.zErrMsg);
}
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", brid);
+ db_add_unsent(brid);
if( manifest_crosslink(brid, &branch, MC_PERMIT_HOOKS)==0 ){
fossil_fatal("%s", g.zErrMsg);
}
assert( blob_is_reset(&branch) );
content_deltify(rootid, &brid, 1, 0);
- zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", brid);
+ zUuid = rid_to_uuid(brid);
fossil_print("New branch: %s\n", zUuid);
if( g.argc==3 ){
fossil_print(
"\n"
"Note: the local check-out has not been updated to the new\n"
@@ -460,11 +460,11 @@
}else if(manifest_crosslink(newRid, &manifest, 0)==0){
fossil_fatal("Crosslinking error: %s", g.zErrMsg);
}
fossil_print("Saved new control artifact %z (RID %d).\n",
rid_to_uuid(newRid), newRid);
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", newRid);
+ db_add_unsent(newRid);
if(fDryRun){
fossil_print("Dry-run mode: rolling back new artifact.\n");
assert(0!=doRollback);
}
}
@@ -719,12 +719,12 @@
branch_prepare_list_query(&q, brFlags, zBrNameGlob, nLimit);
while( db_step(&q)==SQLITE_ROW ){
const char *zBr = db_column_text(&q, 0);
int isPriv = zCurrent!=0 && db_column_int(&q, 1)==1;
int isCur = zCurrent!=0 && fossil_strcmp(zCurrent,zBr)==0;
- fossil_print("%s%s%s\n",
- ( (brFlags & BRL_PRIVATE) ? " " : ( isPriv ? "#" : " ") ),
+ fossil_print("%s%s%s\n",
+ ( (brFlags & BRL_PRIVATE) ? " " : ( isPriv ? "#" : " ") ),
(isCur ? "* " : " "), zBr);
}
db_finalize(&q);
}else if( strncmp(zCmd,"new",n)==0 ){
branch_new();
Index: src/checkin.c
==================================================================
--- src/checkin.c
+++ src/checkin.c
@@ -2665,11 +2665,11 @@
if( rid>0 ){
content_deltify(rid, &nrid, 1, 0);
}
db_multi_exec("UPDATE vfile SET mrid=%d, rid=%d, mhash=NULL WHERE id=%d",
nrid,nrid,id);
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", nrid);
+ db_add_unsent(nrid);
}
}
db_finalize(&q);
if( nConflict && !allowConflict ){
fossil_fatal("abort due to unresolved merge conflicts; "
@@ -2763,11 +2763,11 @@
nvid = content_put(&manifest);
if( nvid==0 ){
fossil_fatal("trouble committing manifest: %s", g.zErrMsg);
}
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", nvid);
+ db_add_unsent(nvid);
if( manifest_crosslink(nvid, &manifest,
dryRunFlag ? MC_NONE : MC_PERMIT_HOOKS)==0 ){
fossil_fatal("%s", g.zErrMsg);
}
assert( blob_is_reset(&manifest) );
Index: src/db.c
==================================================================
--- src/db.c
+++ src/db.c
@@ -4650,10 +4650,17 @@
** to obtain a check-in lock during auto-sync, the server will
** send the "pragma avoid-delta-manifests" statement in its reply,
** which will cause the client to avoid generating a delta
** manifest.
*/
+/*
+** SETTING: forum-close-policy boolean default=off
+** If true, forum moderators may close/re-open forum posts, and reply
+** to closed posts. If false, only administrators may do so. Note that
+** this only affects the forum web UI, not post-closing tags which
+** arrive via the command-line or from synchronization with a remote.
+*/
/*
** SETTING: gdiff-command width=40 default=gdiff sensitive
** The value is an external command to run when performing a graphical
** diff. If undefined, text diff will be used.
*/
@@ -5451,5 +5458,12 @@
fossil_free(zRepo);
}
fossil_free(zCkout);
return rc;
}
+
+/*
+** Adds the given rid to the UNSENT table.
+*/
+void db_add_unsent(int rid){
+ db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", rid);
+}
Index: src/default.css
==================================================================
--- src/default.css
+++ src/default.css
@@ -904,12 +904,39 @@
padding-right: 1ex;
margin-top: 1ex;
display: flex;
flex-direction: column;
}
+div.forumClosed {
+}
+div.forumClosed > .forumPostBody {
+ opacity: 0.7;
+}
+div.forumClosed > .forumPostHdr::before {
+ content: "[CLOSED] ";
+}
+/*div.forumClosed > div.forumPostBody {
+ filter: blur(5px);
+}*/
+div.forumpost-closure-warning {
+ margin-top: 1em;
+ margin-bottom: 1em;
+ border-style: solid;
+ padding: 0.25em 0.5em;
+ background: #f4f400bb;
+ /*font-weight: bold;*/
+}
+div.forumpost-closure-warning input[type=submit] {
+ padding: 0.25em;
+}
+div.forumpost-single-controls {
+ /* UI controls along the bottom of a single post
+ ** in the thread view. */
+}
.forum div > form {
margin: 0.5em 0;
+ display: inline-block;
}
.forum-post-collapser {
/* Common style for the bottom-of-post and right-of-post
expand/collapse widgets. */
font-size: 0.8em;
@@ -1001,10 +1028,30 @@
background-color: #cef;
}
div.forumObs {
color: #bbb;
}
+
+div.setup_forum-column {
+ display: flex;
+ flex-direction: column;
+}
+
+body.cpage-setup_forum > .content table {
+ margin-bottom: 1em;
+}
+body.cpage-setup_forum > .content table.bordered {
+ border: 1px solid;
+ border-radius: 0.25em;
+}
+body.cpage-setup_forum > .content table td,
+body.cpage-setup_forum > .content table th {
+ text-align: left;
+}
+body.cpage-setup_forum table.forum-settings-list > tbody > tr > td {
+ min-width: 2em;
+}
#capabilitySummary {
text-align: center;
}
#capabilitySummary td {
Index: src/event.c
==================================================================
--- src/event.c
+++ src/event.c
@@ -328,11 +328,11 @@
blob_appendf(&event, "W %d\n%s\n", strlen(zBody), zBody);
md5sum_blob(&event, &cksum);
blob_appendf(&event, "Z %b\n", &cksum);
blob_reset(&cksum);
nrid = content_put(&event);
- db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", nrid);
+ db_add_unsent(nrid);
if( manifest_crosslink(nrid, &event, MC_NONE)==0 ){
db_end_transaction(1);
return 0;
}
assert( blob_is_reset(&event) );
Index: src/forum.c
==================================================================
--- src/forum.c
+++ src/forum.c
@@ -47,10 +47,11 @@
ForumPost *pNext; /* Next in chronological order */
ForumPost *pPrev; /* Previous in chronological order */
ForumPost *pDisplay; /* Next in display order */
int nEdit; /* Number of edits to this post */
int nIndent; /* Number of levels of indentation for this post */
+ int iClosed; /* See forum_rid_is_closed() */
};
/*
** A single instance of the following tracks all entries for a thread.
*/
@@ -77,10 +78,285 @@
db_bind_int(&q, "$rid", rid);
res = db_step(&q)==SQLITE_ROW;
db_reset(&q);
return res;
}
+
+/*
+** Given a valid forumpost.fpid value, this function returns the first
+** fpid in the chain of edits for that forum post, or rid if no prior
+** versions are found.
+*/
+static int forumpost_head_rid(int rid){
+ Stmt q;
+ int rcRid = rid;
+
+ db_prepare(&q, "SELECT fprev FROM forumpost"
+ " WHERE fpid=:rid AND fprev IS NOT NULL");
+ db_bind_int(&q, ":rid", rid);
+ while( SQLITE_ROW==db_step(&q) ){
+ rcRid = db_column_int(&q, 0);
+ db_reset(&q);
+ db_bind_int(&q, ":rid", rcRid);
+ }
+ db_finalize(&q);
+ return rcRid;
+}
+
+/*
+** Returns true if p, or any parent of p, has a non-zero iClosed
+** value. Returns 0 if !p. For an edited chain of post, the tag is
+** checked on the pEditHead entry, to simplify subsequent unlocking of
+** the post.
+**
+** If bCheckIrt is true then p's thread in-response-to parents are
+** checked (recursively) for closure, else only p is checked.
+*/
+static int forumpost_is_closed(ForumPost *p, int bCheckIrt){
+ while(p){
+ if( p->pEditHead ) p = p->pEditHead;
+ if( p->iClosed || !bCheckIrt ) return p->iClosed;
+ p = p->pIrt;
+ }
+ return 0;
+}
+
+/*
+** Given a forum post RID, this function returns true if that post has
+** (or inherits) an active "closed" tag. If bCheckIrt is true then
+** the post to which the given post responds is also checked
+** (recursively), else they are not. When checking in-response-to
+** posts, the first one which is closed ends the search.
+**
+** Note that this function checks _exactly_ the given rid, whereas
+** forum post closure/re-opening is always applied to the head of an
+** edit chain so that we get consistent implied locking beheavior for
+** later versions and responses to arbitrary versions in the
+** chain. Even so, the "closed" tag is applied as a propagating tag
+** so will apply to all edits in a given chain.
+**
+** The return value is one of:
+**
+** - 0 if no "closed" tag is found.
+**
+** - The tagxref.rowid of the tagxref entry for the closure if rid is
+** the forum post to which the closure applies.
+**
+** - (-tagxref.rowid) if the given rid inherits a "closed" tag from an
+** IRT forum post.
+*/
+static int forum_rid_is_closed(int rid, int bCheckIrt){
+ static Stmt qIrt = empty_Stmt_m;
+ int rc = 0, i = 0;
+ /* TODO: this can probably be turned into a CTE by someone with
+ ** superior SQL-fu. */
+ for( ; rid; i++ ){
+ rc = rid_has_active_tag_name(rid, "closed");
+ if( rc || !bCheckIrt ) break;
+ else if( !qIrt.pStmt ) {
+ db_static_prepare(&qIrt,
+ "SELECT firt FROM forumpost "
+ "WHERE fpid=$fpid ORDER BY fmtime DESC"
+ );
+ }
+ db_bind_int(&qIrt, "$fpid", rid);
+ rid = SQLITE_ROW==db_step(&qIrt) ? db_column_int(&qIrt, 0) : 0;
+ db_reset(&qIrt);
+ }
+ return i ? -rc : rc;
+}
+
+/*
+** Closes or re-opens the given forum RID via addition of a new
+** control artifact into the repository. In order to provide
+** consistent behavior for implied closing of responses and later
+** versions, it always acts on the first version of the given forum
+** post, walking the forumpost.fprev values to find the head of the
+** chain.
+**
+** If doClose is true then a propagating "closed" tag is added, except
+** as noted below, with the given optional zReason string as the tag's
+** value. If doClose is false then any active "closed" tag on frid is
+** cancelled, except as noted below. zReason is ignored if doClose is
+** false or if zReason is NULL or starts with a NUL byte.
+**
+** This function only adds a "closed" tag if forum_rid_is_closed()
+** indicates that frid's head is not closed. If a parent post is
+** already closed, no tag is added. Similarly, it will only remove a
+** "closed" tag from a post which has its own "closed" tag, and will
+** not remove an inherited one from a parent post.
+**
+** If doClose is true and frid is closed (directly or inherited), this
+** is a no-op. Likewise, if doClose is false and frid itself is not
+** closed (not accounting for an inherited closed tag), this is a
+** no-op.
+**
+** Returns true if it actually creates a new tag, else false. Fails
+** fatally on error. If it returns true then any ForumPost::iClosed
+** values from previously loaded posts are invalidated if they refer
+** to the amended post or a response to it.
+**
+** Sidebars:
+**
+** - Unless the caller has a transaction open, via
+** db_begin_transaction(), there is a very tiny race condition
+** window during which the caller's idea of whether or not the forum
+** post is closed may differ from the current repository state.
+**
+** - This routine assumes that frid really does refer to a forum post.
+**
+** - This routine assumes that frid is not private or pending
+** moderation.
+**
+** - Closure of a forum post requires a propagating "closed" tag to
+** account for how edits of posts are handled. This differs from
+** closure of a branch, where a non-propagating tag is used.
+*/
+static int forumpost_close(int frid, int doClose, const char *zReason){
+ Blob artifact = BLOB_INITIALIZER; /* Output artifact */
+ Blob cksum = BLOB_INITIALIZER; /* Z-card */
+ int iClosed; /* true if frid is closed */
+ int trid; /* RID of new control artifact */
+ char *zUuid; /* UUID of head version of post */
+
+ db_begin_transaction();
+ frid = forumpost_head_rid(frid);
+ iClosed = forum_rid_is_closed(frid, 1);
+ if( (iClosed && doClose
+ /* Already closed, noting that in the case of (iClosed<0), it's
+ ** actually a parent which is closed. */)
+ || (iClosed<=0 && !doClose
+ /* This entry is not closed, but a parent post may be. */) ){
+ db_end_transaction(0);
+ return 0;
+ }
+ if( doClose==0 || (zReason && !zReason[0]) ){
+ zReason = 0;
+ }
+ zUuid = rid_to_uuid(frid);
+ blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" ));
+ blob_appendf(&artifact,
+ "T %cclosed %s%s%F\n",
+ doClose ? '*' : '-', zUuid,
+ zReason ? " " : "", zReason ? zReason : "");
+ blob_appendf(&artifact, "U %F\n", login_name());
+ md5sum_blob(&artifact, &cksum);
+ blob_appendf(&artifact, "Z %b\n", &cksum);
+ blob_reset(&cksum);
+ trid = content_put_ex(&artifact, 0, 0, 0, 0);
+ if( trid==0 ){
+ fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
+ }
+ if( manifest_crosslink(trid, &artifact,
+ MC_NONE /*MC_PERMIT_HOOKS?*/)==0 ){
+ fossil_fatal("%s", g.zErrMsg);
+ }
+ assert( blob_is_reset(&artifact) );
+ db_add_unsent(trid);
+ admin_log("%s forum post %S", doClose ? "Close" : "Re-open", zUuid);
+ fossil_free(zUuid);
+ /* Potential TODO: if (iClosed>0) then we could find the initial tag
+ ** artifact and content_deltify(thatRid,&trid,1,0). Given the tiny
+ ** size of these artifacts, however, that would save little space,
+ ** if any. */
+ db_end_transaction(0);
+ return 1;
+}
+
+/*
+** Returns true if the forum-close-policy setting is true, else false,
+** caching the result for subsequent calls.
+*/
+static int forumpost_close_policy(void){
+ static int closePolicy = -99;
+
+ if( closePolicy==-99 ){
+ closePolicy = db_get_boolean("forum-close-policy",0)>0;
+ }
+ return closePolicy;
+}
+
+/*
+** Returns 1 if the current user is an admin, -1 if the current user
+** is a forum moderator and the forum-close-policy setting is true,
+** else returns 0. The value is cached for subsequent calls.
+*/
+static int forumpost_may_close(void){
+ static int permClose = -99;
+ if( permClose!=-99 ){
+ return permClose;
+ }else if( g.perm.Admin ){
+ return permClose = 1;
+ }else if( g.perm.ModForum ){
+ return permClose = forumpost_close_policy()>0 ? -1 : 0;
+ }else{
+ return permClose = 0;
+ }
+}
+
+/*
+** If iClosed is true and the current user forumpost-close privileges,
+** this renders either a checkbox to unlock forum post fpid (if
+** iClosed>0) or a SPAN.warning element that the given post inherits
+** the CLOSED status from a parent post (if iClosed<0). If neither of
+** the initial conditions is true, this is a no-op.
+*/
+static void forumpost_emit_closed_state(int fpid, int iClosed){
+ const char *zCommon;
+ int iHead = forumpost_head_rid(fpid);
+ const int permClose = forumpost_may_close();
+
+ zCommon = forumpost_close_policy()==0
+ ? "Admins may close or re-open posts, or respond to closed posts."
+ : "Admins or moderators "
+ "may close or re-open posts, or respond to closed posts.";
+ /*@ forumpost_emit_closed_state(%d(fpid), %d(iClosed))
*/
+ if( iHead != fpid ){
+ iClosed = forum_rid_is_closed(iHead, 1);
+ /*@ forumpost_emit_closed_state() %d(iHead), %d(iClosed)*/
+ }
+ if( iClosed<0 ){
+ @
Forum posts: + @ %d(nPosts)
+ } + + @Users with capabilities 's', 'a', or '6'.
+ { + Stmt q = empty_Stmt; + int nRows = 0; + db_prepare(&q, "SELECT uid, login, cap FROM user " + "WHERE cap GLOB '*[as6]*' ORDER BY login"); + @User | Capabilities |
---|---|
%h(zUser) | + @(%h(zCap)) | + @
Users with capability '5'.
+ { + Stmt q = empty_Stmt; + int nRows = 0; + db_prepare(&q, "SELECT uid, login, cap FROM user " + "WHERE cap GLOB '*5*' ORDER BY login"); + @User | Capabilities |
---|---|
%h(zUser) | + @(%h(zCap)) | + @
Configuration settings specific to the forum.
+ if( P("submit") && cgi_csrf_safe(1) ){ + int i = 0; + const char *zSetting; + login_verify_csrf_secret(); + db_begin_transaction(); + while( (zSetting = zSettingsBool[i++]) ){ + const char *z = P(zSetting); + if( !z || !z[0] ) z = "off"; + db_set(zSetting/*works-like:"x"*/, z, 0); + } + db_end_transaction(0); + @Settings saved.
+ } + { + int i = 0; + const char *zSetting; + @+ onoff_attribute("", zSetting, zSetting/*works-like:"x"*/, 0, 0); + @ | + @ %h(zSetting) + @ |
Time | Index: src/tag.c ================================================================== --- src/tag.c +++ src/tag.c @@ -903,5 +903,36 @@ " AND tag.tagid=%d" " AND tagxref.tagid=tag.tagid", rid, tagId ); } + + +/* +** Returns tagxref.rowid if the given blob.rid has a tagxref.rid entry +** of an active (non-cancelled) tag matching the given rid and tag +** name string, else returns 0. Note that this function does not +** distinguish between a non-existent tag and a cancelled tag. +** +** Design note: the return value is the tagxref.rowid because that +** gives us an easy way to fetch the value of the tag later on, if +** needed. +*/ +int rid_has_active_tag_name(int rid, const char *zTagName){ + static Stmt q = empty_Stmt_m; + int rc; + + assert( 0 != zTagName ); + if( !q.pStmt ){ + db_static_prepare(&q, + "SELECT x.rowid FROM tagxref x, tag t" + " WHERE x.rid=$rid AND x.tagtype>0 " + " AND x.tagid=t.tagid" + " AND t.tagname=$tagname" + ); + } + db_bind_int(&q, "$rid", rid); + db_bind_text(&q, "$tagname", zTagName); + rc = (SQLITE_ROW==db_step(&q)) ? db_column_int(&q, 0) : 0; + db_reset(&q); + return rc; +} Index: src/tkt.c ================================================================== --- src/tkt.c +++ src/tkt.c @@ -864,11 +864,11 @@ db_multi_exec( "INSERT INTO modreq(objid, tktid) VALUES(%d,%Q)", rid, zTktId ); }else{ - db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d);", rid); + db_add_unsent(rid); db_multi_exec("INSERT OR IGNORE INTO unclustered VALUES(%d);", rid); } result = (manifest_crosslink(rid, pTicket, MC_NONE)==0); assert( blob_is_reset(pTicket) ); if( !result ){ Index: src/wiki.c ================================================================== --- src/wiki.c +++ src/wiki.c @@ -635,11 +635,11 @@ }else{ nrid = content_put_ex(pWiki, 0, 0, 0, 1); moderation_table_create(); db_multi_exec("INSERT INTO modreq(objid) VALUES(%d)", nrid); } - db_multi_exec("INSERT OR IGNORE INTO unsent VALUES(%d)", nrid); + db_add_unsent(nrid); db_multi_exec("INSERT OR IGNORE INTO unclustered VALUES(%d);", nrid); manifest_crosslink(nrid, pWiki, MC_NONE); if( login_is_individual() ){ alert_user_contact(login_name()); }
---|