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 ){ + @
\ + @ This post is CLOSED via a parent post. %s(zCommon)\ + @
+ return; + } + else if( iClosed==0 ){ + if( permClose==0 ) return; + @
+ @
+ @ + @ + @ %s(zCommon) + @ This does NOT save any pending changes in + @ the editor! + @
+ return; + } + assert( iClosed>0 ); + /* Only show the "unlock" option on a post which is actually + ** closed, not on a post which inherits that state. */ + @
\ + @ This post is CLOSED. %s(zCommon) + if( permClose ){ + @
+ @ + @ + @ This does NOT save any pending changes in + @ the editor! + @
+ } + @
+} + +/* +** Emits a warning that the current forum post is CLOSED and can only +** be edited or responded to by an administrator. */ +static void forumpost_error_closed(void){ + @
This (sub)thread is CLOSED and can only be + @ edited or replied to by an admin user.
+} /* ** Delete a complete ForumThread and all its entries. */ static void forumthread_delete(ForumThread *pThread){ @@ -215,10 +491,13 @@ for(; p; p=p->pEditPrev ){ p->nEdit = pPost->nEdit; p->pEditTail = pPost; } } + pPost->iClosed = forum_rid_is_closed(pPost->pEditHead + ? pPost->pEditHead->fpid + : pPost->fpid, 1); } db_finalize(&q); if( computeHierarchy ){ /* Compute the hierarchical display order */ @@ -300,25 +579,31 @@ pThread = forumthread_create(froot, 1); fossil_print("Chronological:\n"); fossil_print( /* 0 1 2 3 4 5 6 7 */ /* 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123 */ - " sid rev fpid pIrt pEditPrev pEditTail hash\n"); + " sid rev closed fpid pIrt pEditPrev pEditTail hash\n"); for(p=pThread->pFirst; p; p=p->pNext){ - fossil_print("%4d %4d %9d %9d %9d %9d %8.8s\n", p->sid, p->rev, + fossil_print("%4d %4d %7d %9d %9d %9d %9d %8.8s\n", + p->sid, p->rev, + p->iClosed, p->fpid, p->pIrt ? p->pIrt->fpid : 0, p->pEditPrev ? p->pEditPrev->fpid : 0, p->pEditTail ? p->pEditTail->fpid : 0, p->zUuid); } fossil_print("\nDisplay\n"); for(p=pThread->pDisplay; p; p=p->pDisplay){ fossil_print("%*s", (p->nIndent-1)*3, ""); if( p->pEditTail ){ - fossil_print("%d->%d\n", p->fpid, p->pEditTail->fpid); + fossil_print("%d->%d", p->fpid, p->pEditTail->fpid); }else{ - fossil_print("%d\n", p->fpid); + fossil_print("%d", p->fpid); + } + if( p->iClosed ){ + fossil_print(" [closed%s]", p->iClosed<0 ? " via parent" : ""); } + fossil_print("\n"); } forumthread_delete(pThread); } /* @@ -511,23 +796,25 @@ char *zHist; /* History query string */ Manifest *pManifest; /* Manifest comprising the current post */ int bPrivate; /* True for posts awaiting moderation */ int bSameUser; /* True if author is also the reader */ int iIndent; /* Indent level */ + int iClosed; /* True if (sub)thread is closed */ const char *zMimetype;/* Formatting MIME type */ /* Get the manifest for the post. Abort if not found (e.g. shunned). */ pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0); if( !pManifest ) return; - + iClosed = forumpost_is_closed(p, 1); /* When not in raw mode, create the border around the post. */ if( !bRaw ){ /* Open the
enclosing the post. Set the class string to mark the post ** as selected and/or obsolete. */ iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1; @
}else{ @ > @@ -543,11 +830,11 @@ ** * The post is unedited ** * The post was last edited by the original author ** * The post was last edited by a different person */ if( p->pEditHead ){ - zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", + zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", p->pEditHead->rDate); }else{ zPosterName = forum_post_display_name(p, pManifest); zEditorName = zPosterName; } @@ -555,11 +842,11 @@ if( p->pEditPrev ){ zPosterName = forum_post_display_name(p->pEditHead, 0); zEditorName = forum_post_display_name(p, pManifest); zHist = bHist ? "" : zQuery[0]==0 ? "?hist" : "&hist"; @

(%d(p->sid)\ - @ .%0*d(fossil_num_digits(p->nEdit))(p->rev)) \ + @ .%0*d(fossil_num_digits(p->nEdit))(p->rev)) if( fossil_strcmp(zPosterName, zEditorName)==0 ){ @ By %s(zPosterName) on %h(zDate) edited from \ @ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ @ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev) }else{ @@ -568,11 +855,11 @@ @ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ @ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev) } }else{ zPosterName = forum_post_display_name(p, pManifest); - @

(%d(p->sid)) \ + @

(%d(p->sid)) @ By %s(zPosterName) on %h(zDate) } fossil_free(zDate); @@ -631,18 +918,26 @@ /* When not in raw mode, finish creating the border around the post. */ if( !bRaw ){ /* If the user is able to write to the forum and if this post has not been ** edited, create a form with various interaction buttons. */ if( g.perm.WrForum && !p->pEditTail ){ - @
+ @
\ + @ @ if( !bPrivate ){ - /* Reply and Edit are only available if the post has been approved. */ - @ - if( g.perm.Admin || bSameUser ){ - @ - @ + /* Reply and Edit are only available if the post has been + ** approved. Closed threads can only be edited or replied to + ** if forumpost_may_close() is true but a user may delete + ** their own posts even if they are closed. */ + if( forumpost_may_close() || !iClosed ){ + @ + if( g.perm.Admin || (bSameUser && !iClosed) ){ + @ + } + if( g.perm.Admin || bSameUser ){ + @ + } } }else if( g.perm.ModForum ){ /* Allow moderators to approve or reject pending posts. Also allow ** forum supervisors to mark non-special users as trusted and therefore ** able to post unmoderated. */ @@ -657,11 +952,20 @@ } }else if( bSameUser ){ /* Allow users to delete (reject) their own pending posts. */ @ } - @
+ @ + if( bSelect && forumpost_may_close() && iClosed>=0 ){ + int iHead = forumpost_head_rid(p->fpid); + @
+ @ + @ + @
+ } + @
} @

} /* Clean up. */ @@ -1040,10 +1344,15 @@ Blob x, cksum, formatCheck, errMsg; Manifest *pPost; int nContent = zContent ? (int)strlen(zContent) : 0; schema_forum(); + if( !g.perm.Admin && (iEdit || iInReplyTo) + && forum_rid_is_closed(iEdit ? iEdit : iInReplyTo, 1) ){ + forumpost_error_closed(); + return 0; + } if( iEdit==0 && whitespace_only(zContent) ){ return 0; } if( iInReplyTo==0 && iEdit>0 ){ iBasis = iEdit; @@ -1141,10 +1450,44 @@ @ %z(href("%R/markup_help"))Markup style: mimetype_option_menu(zMimetype, "mimetype"); @

} + +/* +** WEBPAGE: forumpost_close hidden +** WEBPAGE: forumpost_reopen hidden +** +** fpid=X Hash of the post to be edited. REQUIRED +** reason=X Optional reason for closure. +** +** Closes or re-opens the given forum post, within the bounds of the +** API for forumpost_close(). After (perhaps) modifying the "closed" +** status of the given thread, it redirects to that post's thread +** view. Requires admin privileges. +*/ +void forum_page_close(void){ + const char *zFpid = PD("fpid",""); + const char *zReason = 0; + int fClose; + int fpid; + + login_check_credentials(); + if( forumpost_may_close()==0 ){ + login_needed(g.anon.Admin); + return; + } + fpid = symbolic_name_to_rid(zFpid, "f"); + if( fpid<=0 ){ + webpage_error("Missing or invalid fpid query parameter"); + } + fClose = sqlite3_strglob("*_close*", g.zPath)==0; + if( fClose ) zReason = PD("reason",0); + forumpost_close(fpid, fClose, zReason); + cgi_redirectf("%R/forumpost/%S",zFpid); + return; +} /* ** WEBPAGE: forumnew ** WEBPAGE: forumedit ** @@ -1152,10 +1495,11 @@ ** But first prompt to see if the user would like to log in. */ void forum_page_init(void){ int isEdit; char *zGoto; + login_check_credentials(); if( !g.perm.WrForum ){ login_needed(g.anon.WrForum); return; } @@ -1291,11 +1635,13 @@ const char *zTitle = 0; char *zDate = 0; const char *zFpid = PD("fpid",""); int isCsrfSafe; int isDelete = 0; + int iClosed = 0; int bSameUser; /* True if author is also the reader */ + int bPreview; /* True in preview mode. */ int bPrivate; /* True if post is private (not yet moderated) */ login_check_credentials(); if( !g.perm.WrForum ){ login_needed(g.anon.WrForum); @@ -1308,13 +1654,15 @@ froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); if( froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0 ){ webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid); } if( P("cancel") ){ - cgi_redirectf("%R/forumpost/%S",P("fpid")); + cgi_redirectf("%R/forumpost/%S",zFpid); return; } + bPreview = P("preview")!=0; + iClosed = forum_rid_is_closed(fpid, 1); isCsrfSafe = cgi_csrf_safe(1); bPrivate = content_is_private(fpid); bSameUser = login_is_individual() && fossil_strcmp(pPost->zUser, g.zLogin)==0; if( isCsrfSafe && (g.perm.ModForum || (bPrivate && bSameUser)) ){ @@ -1376,10 +1724,11 @@ if( pPost->zThreadTitle ) zTitle = ""; style_header("Delete %s", zTitle ? "Post" : "Reply"); @

Original Post:

forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); + forumpost_emit_closed_state(fpid, iClosed); @

Change Into:

forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); @
@ @ @@ -1400,11 +1749,11 @@ } style_header("Edit %s", zTitle ? "Post" : "Reply"); @

Original Post:

forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); - if( P("preview") ){ + if( bPreview ){ @

Preview of Edited Post:

forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); } @

Revised Message:

@ @@ -1429,11 +1778,11 @@ zDisplayName = display_name_from_login(pPost->zUser); @

By %s(zDisplayName) on %h(zDate)

fossil_free(zDisplayName); fossil_free(zDate); forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); - if( P("preview") && !whitespace_only(zContent) ){ + if( bPreview && !whitespace_only(zContent) ){ @

Preview:

forum_render(0, zMimetype,zContent, "forumEdit", 1); } @

Enter Reply:

@ @@ -1444,16 +1793,140 @@ } if( !isDelete ){ @ } @ - if( (P("preview") && !whitespace_only(zContent)) || isDelete ){ - @ + if( (bPreview && !whitespace_only(zContent)) || isDelete ){ + if( !iClosed || g.perm.Admin ) { + @ + } } forum_render_debug_options(); @
forum_emit_js(); + forumpost_emit_closed_state(fpid, iClosed); + style_finish_page(); +} + +/* +** WEBPAGE: setup_forum +** +** Forum configuration and metrics. +*/ +void forum_setup(void){ + /* boolean config settings specific to the forum. */ + const char * zSettingsBool[] = { + "forum-close-policy", + NULL /* sentinel entry */ + }; + + login_check_credentials(); + if( !g.perm.Setup ){ + login_needed(g.anon.Setup); + return; + } + style_set_current_feature("forum"); + style_header("Forum Setup"); + + @

Metrics

+ { + int nPosts = db_int(0, "SELECT COUNT(*) FROM event WHERE type='f'"); + @

Forum posts: + @ %d(nPosts)

+ } + + @

Supervisors

+ @

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"); + @ + @ + @ + while( SQLITE_ROW==db_step(&q) ){ + const int iUid = db_column_int(&q, 0); + const char *zUser = db_column_text(&q, 1); + const char *zCap = db_column_text(&q, 2); + ++nRows; + @ + @ + @ + @ + } + db_finalize(&q); + @
UserCapabilities
%h(zUser)(%h(zCap))
+ if( 0==nRows ){ + @ No supervisors + }else{ + @ %d(nRows) supervisor(s) + } + } + + @

Moderators

+ @

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"); + @ + @ + @ + while( SQLITE_ROW==db_step(&q) ){ + const int iUid = db_column_int(&q, 0); + const char *zUser = db_column_text(&q, 1); + const char *zCap = db_column_text(&q, 2); + ++nRows; + @ + @ + @ + @ + } + db_finalize(&q); + @
UserCapabilities
%h(zUser)(%h(zCap))
+ if( 0==nRows ){ + @ No non-supervisor moderators + }else{ + @ %d(nRows) moderator(s) + } + } + + @

Settings

+ @

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; + @
+ login_insert_csrf_secret(); + @ + while( (zSetting = zSettingsBool[i++]) ){ + @ + } + @
+ onoff_attribute("", zSetting, zSetting/*works-like:"x"*/, 0, 0); + @ + @ %h(zSetting) + @
+ @ + @
+ } + style_finish_page(); } /* ** WEBPAGE: forummain Index: src/json_branch.c ================================================================== --- src/json_branch.c +++ src/json_branch.c @@ -293,11 +293,11 @@ brid = content_put_ex(&branch, 0, 0, 0, zOpt->isPrivate); if( brid==0 ){ fossil_panic("Problem 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_panic("%s", g.zErrMsg); } assert( blob_is_reset(&branch) ); content_deltify(rootid, &brid, 1, 0); Index: src/setup.c ================================================================== --- src/setup.c +++ src/setup.c @@ -130,10 +130,12 @@ "Configure the wiki for this repository"); setup_menu_entry("Interwiki Map", "intermap", "Mapping keywords for interwiki links"); setup_menu_entry("Chat", "setup_chat", "Configure the chatroom"); + setup_menu_entry("Forum", "setup_forum", + "Forum config and metrics"); } setup_menu_entry("Search","srchsetup", "Configure the built-in search engine"); setup_menu_entry("URL Aliases", "waliassetup", "Configure URL aliases"); @@ -1972,11 +1974,11 @@ @

[Newer]

} db_prepare(&stLog, "SELECT datetime(time,'unixepoch'), who, page, what " "FROM admin_log " - "ORDER BY time DESC"); + "ORDER BY time DESC, rowid DESC"); style_table_sorter(); @ @ @ 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()); }
Time