diff --git a/conference.go b/conference.go index 2c8b75d..bad77c9 100644 --- a/conference.go +++ b/conference.go @@ -12,6 +12,7 @@ package main import ( "errors" + "fmt" "net/http" "git.erbosoft.com/amy/amsterdam/database" @@ -34,7 +35,7 @@ func conferencesPrequel(ctxt ui.AmContext) (string, any, error) { ctxt.SetRC(http.StatusNotFound) return ui.ErrorPage(ctxt, errors.New("this community does not use conferencing services")) } - if comm.MembersOnly && !ctxt.IsMember() { + if comm.MembersOnly && !ctxt.IsMember() && !ctxt.TestPermission("Community.NoJoinRequired") { ctxt.SetRC(http.StatusForbidden) return ui.ErrorPage(ctxt, errors.New("you are not a member of this community")) } @@ -45,6 +46,29 @@ func conferencesPrequel(ctxt ui.AmContext) (string, any, error) { return "", nil, nil } +func singleConferencePrequel(ctxt ui.AmContext) (string, any, error) { + cmd, arg, err := conferencesPrequel(ctxt) + if cmd != "" { + return cmd, arg, err + } + var conf *database.Conference + conf, err = database.AmGetConferenceByAliasInCommunity(ctxt.CurrentCommunity().Id, ctxt.URLParam("confid")) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + m, lvl, err := conf.Membership(ctxt.CurrentUser()) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + myLevel := ctxt.EffectiveLevel() + if m && lvl > myLevel { + myLevel = lvl + } + ctxt.SetScratch("currentConference", conf) + ctxt.SetScratch("levelInConference", myLevel) + return "", nil, nil +} + /* Conferences displayes the list of conferences in a community. * Parameters: * ctxt - The AmContext for the request. @@ -70,3 +94,67 @@ func Conferences(ctxt ui.AmContext) (string, any, error) { ctxt.VarMap().Set("conferences", clist) return "framed_template", "conflist.jet", err } + +func Topics(ctxt ui.AmContext) (string, any, error) { + cmd, arg, err := singleConferencePrequel(ctxt) + if cmd != "" { + return cmd, arg, err + } + prefs, err := ctxt.CurrentUser().Prefs() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + comm := ctxt.CurrentCommunity() + conf := ctxt.GetScratch("currentConference").(*database.Conference) + myLevel := ctxt.GetScratch("levelInConference").(uint16) + if !conf.TestPermission("Conference.Read", myLevel) { + ctxt.SetRC(http.StatusForbidden) + return ui.ErrorPage(ctxt, errors.New("you are not permitted to read this conference")) + } + + // Get view and sort parameters from query, session, or defaults. Store to session. + trustSessionValues := false + if ctxt.IsSession("topic.conf") { + v := ctxt.GetSession("topic.conf").(int32) + if v == conf.ConfId { + trustSessionValues = true + } else { + ctxt.SetSession("topic.conf", conf.ConfId) + } + } + view := database.TopicViewActive + if trustSessionValues && ctxt.IsSession("topic.view") { + view = ctxt.GetSession("topic.view").(int) + } + view = ctxt.QueryParamInt("view", view) + ctxt.SetSession("topic.view", view) + sort := database.TopicSortNumber + if trustSessionValues && ctxt.IsSession("topic.sort") { + sort = ctxt.GetSession("topic.sort").(int) + } + sort = ctxt.QueryParamInt("sort", sort) + ctxt.SetSession("topic.sort", sort) + + topics, err := database.AmListTopics(conf.ConfId, ctxt.CurrentUserId(), view, sort, false) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + + tz := prefs.Location() + loc := prefs.Localizer() + fdate := make([]string, len(topics)) + for i, t := range topics { + fdate[i] = loc.Strftime("%x %X", t.LastUpdate.In(tz)) + } + + ctxt.VarMap().Set("conferenceName", conf.Name) + ctxt.VarMap().Set("urlBack", fmt.Sprintf("/comm/%s/conf", comm.Alias)) + ctxt.VarMap().Set("urlStem", fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.URLParam("confid"))) + ctxt.VarMap().Set("permalink", "TODO") + ctxt.VarMap().Set("view", view) + ctxt.VarMap().Set("sort", sort) + ctxt.VarMap().Set("topics", topics) + ctxt.VarMap().Set("formattedDate", fdate) + ctxt.VarMap().Set("amsterdam_pageTitle", "Topics in "+conf.Name) + return "framed_template", "topiclist.jet", nil +} diff --git a/database/conference.go b/database/conference.go index fc4481b..cd964f0 100644 --- a/database/conference.go +++ b/database/conference.go @@ -10,6 +10,7 @@ package database import ( + "errors" "fmt" "sync" "time" @@ -204,6 +205,28 @@ func AmGetConferenceByAlias(alias string) (*Conference, error) { return AmGetConference(int32(confid.(int))) } +/* AmGetConferenceByAliasInCommunity returns a conference in a community given its alias. + * Parameters: + * cid - The community to look inside. + * alias - The alias to look up. + * Returns: + * Pointer to the conference, or nil. + * Standard Go error status. + */ +func AmGetConferenceByAliasInCommunity(cid int32, alias string) (*Conference, error) { + rs, err := amdb.Query(`SELECT c.confid FROM commtoconf c, confalias a WHERE c.confid = a.confid + AND c.commid = ? AND a.alias = ?`, cid, alias) + if err != nil { + return nil, err + } + if !rs.Next() { + return nil, errors.New("conference not found") + } + var confid int32 + rs.Scan(&confid) + return AmGetConference(confid) +} + /* AmGetCommunityConferences returns all conferences for a given community. * Parameters: * cid - Community ID to get conferences for. diff --git a/database/topic.go b/database/topic.go index f848675..aab4f33 100644 --- a/database/topic.go +++ b/database/topic.go @@ -9,8 +9,13 @@ // The database package contains database management and storage logic. package database -import "time" +import ( + "errors" + "strings" + "time" +) +// Topic is the top-level structure detailing topics. type Topic struct { TopicId int32 `db:"topicid"` ConfId int32 `db:"confid"` @@ -25,6 +30,7 @@ type Topic struct { Name string `db:"name"` } +// TopicSettings contains per-user settings for topics, including the "last read" message pointer. type TopicSettings struct { TopicId int32 `db:"topicid"` Uid int32 `db:"uid"` @@ -34,3 +40,156 @@ type TopicSettings struct { LastPost *time.Time `db:"last_post"` Subscribe bool `db:"subscribe"` } + +// TopicSummary is a smaller data structure that gets topic information to create the topic list display. +type TopicSummary struct { + TopicID int32 + Number int16 + Name string + Unread int32 + Total int32 + LastUpdate time.Time + Frozen bool + Archived bool + Subscribed bool +} + +// View and sort constants for AmListTopics. +const ( + TopicViewAll = 0 + TopicViewNew = 1 + TopicViewActive = 2 + TopicViewAllVisible = 3 + TopicViewHidden = 4 + TopicViewArchive = 5 + + TopicSortID = 0 + TopicSortNumber = 1 + TopicSortName = 2 + TopicSortUnread = 3 + TopicSortTotal = 4 + TopicSortDate = 5 +) + +/* AmListTopics produces a list of topic summary information according to specific options. + * Parameters: + * confid - The ID of the conference to list topics in. + * uid - The UID of the user to consider the settings of. + * viewOption - One of the following constants: + * TopicViewAll - List all topics. + * TopicViewNew - List only visible topics with new messages. + * TopicViewActive - List only visible topics, with "active" ones coming first. + * TopicViewAllVisible - List only visible topics. + * TopicViewHidden - List only hidden topics (including archived ones). + * TopicViewArchive - List only archived, non-hidden topics. + * sortOption - One of the following constants: + * TopicSortID - Sort by topic ID. + * TopicSortNumber - Sort by topic number in the conference. May be negated to sort in reverse order. + * TopicSortName - Sort by topic name. May be negated to sort in reverse order. + * TopicSortUnread - Sort by number of unread messages. May be negated to sort in reverse order. + * TopicSortTotal - Sort by total number of messages. May be negated to sort in reverse order. + * TopicSortDate - Sort by last topic update date. May be negated to sort in reverse order. + * ignoreSticky - If false, sticky topics will precede nonsticky ones; if true, stickiness is ignored. + * Returns: + * List of TopicSummary pointers. + * Standard Go error status. + */ +func AmListTopics(confid int32, uid int32, viewOption int, sortOption int, ignoreSticky bool) ([]*TopicSummary, error) { + // Decode the viewOption into a WHERE clause. + var whereClause string + switch viewOption { + case TopicViewAll: + whereClause = "" + case TopicViewNew: + tail := "t.top_message > IFNULL(s.last_message,-1)" + if !ignoreSticky { + tail = "(t.sticky = 1 OR " + tail + ")" + } + whereClause = "t.archived = 0 AND IFNULL(s.hidden,0) = 0 AND " + tail + case TopicViewActive, TopicViewAllVisible: + whereClause = "t.archived = 0 AND IFNULL(s.hidden,0) = 0" + case TopicViewHidden: + whereClause = "IFNULL(s.hidden,0) = 1" + case TopicViewArchive: + whereClause = "t.archived = 1 AND IFNULL(s.hidden,0) = 0" + default: + return nil, errors.New("invalid view option specified") + } + + // Decode the sortOption into an ORDER BY clause. + var reverse bool = false + if sortOption < 0 { + reverse = true + sortOption = -sortOption + } + var orderByClause string + switch sortOption { + case TopicSortID: + orderByClause = "t.topicid ASC" + case TopicSortNumber: + if reverse { + orderByClause = "t.num DESC" + } else { + orderByClause = "t.num ASC" + } + case TopicSortName: + if reverse { + orderByClause = "t.name DESC, t.num DESC" + } else { + orderByClause = "t.name ASC, t.num ASC" + } + case TopicSortUnread: + if reverse { + orderByClause = "unread ASC, t.num DESC" + } else { + orderByClause = "unread DESC, t.num ASC" + } + case TopicSortTotal: + if reverse { + orderByClause = "total ASC, t.num DESC" + } else { + orderByClause = "total DESC, t.num ASC" + } + case TopicSortDate: + if reverse { + orderByClause = "t.lastupdate ASC, t.num DESC" + } else { + orderByClause = "t.lastupdate DESC, t.num ASC" + } + default: + return nil, errors.New("invalid sort option specified") + } + + // Build the full SQL statement + var fullStatement strings.Builder + fullStatement.WriteString("SELECT t.topicid, t.num, t.name, (t.top_message - IFNULL(s.last_message,-1)) AS unread, ") + fullStatement.WriteString("(t.top_message + 1) AS total, t.lastupdate, t.frozen, t.archived, IFNULL(s.subscribe,0) AS subscribe, ") + fullStatement.WriteString("t.sticky, GREATEST(SIGN(t.top_message - IFNULL(s.last_message,-1)),0) AS newflag ") + fullStatement.WriteString("FROM topics t LEFT JOIN topicsettings s ON t.topicid = s.topicid AND s.uid = ? WHERE t.confid = ? ") + if whereClause != "" { + fullStatement.WriteString("AND ") + fullStatement.WriteString(whereClause) + } + fullStatement.WriteString(" ORDER BY ") + if ignoreSticky { + fullStatement.WriteString("t.sticky DESC, ") + } + if viewOption == TopicViewActive { + fullStatement.WriteString("newflag DESC, ") + } + fullStatement.WriteString(orderByClause) + + // Execute and capture results + rs, err := amdb.Query(fullStatement.String(), uid, confid) + if err != nil { + return nil, err + } + rc := make([]*TopicSummary, 0) + for rs.Next() { + var rec TopicSummary + rs.Scan(&rec.TopicID, &rec.Number, &rec.Name, &rec.Unread, &rec.Total, &rec.LastUpdate, &rec.Frozen, &rec.Archived, + &rec.Subscribed) + rc = append(rc, &rec) + } + return rc, nil +} diff --git a/main.go b/main.go index a7d80ce..6ab9ac7 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,7 @@ func setupEcho() *echo.Echo { e.GET("/comm/:cid/admin/logo", ui.AmWrap(CommunityLogoForm)) e.POST("/comm/:cid/admin/logo", ui.AmWrap(EditCommunityLogo)) e.GET("/comm/:cid/conf", ui.AmWrap(Conferences)) + e.GET("/comm/:cid/conf/:confid", ui.AmWrap(Topics)) return e } diff --git a/setup/database.sql b/setup/database.sql index cf617c6..7696a42 100644 --- a/setup/database.sql +++ b/setup/database.sql @@ -1009,7 +1009,7 @@ INSERT INTO commmember (commid, uid, granted_lvl, locked) # (CONFID = 2) INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl, delete_lvl, top_topic, name, descr) - VALUES (2, '2000-12-01 00:00:00', 6500, 6500, 6500, 52500, 52500, 52500, 58000, 0, 'General Discussion', + VALUES (2, '2000-12-01 00:00:00', 2500, 2500, 2500, 60500, 60500, 60500, 61000, 0, 'General Discussion', 'Your place for general discussion about the system and general topics.'); INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 2, 10); INSERT INTO confalias (confid, alias) VALUES (2, 'General'); @@ -1022,7 +1022,7 @@ INSERT INTO confmember (confid, uid, granted_lvl) VALUES (2, 2, 52500); # (CONFID = 3) INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl, delete_lvl, top_topic, name, descr) - VALUES (3, '2000-12-01 00:00:00', 6500, 6500, 6500, 52500, 52500, 52500, 58000, 0, 'Test Postings', + VALUES (3, '2000-12-01 00:00:00', 2500, 2500, 2500, 60500, 60500, 60500, 61000, 0, 'Test Postings', 'Use this conference to test the conferencing system.'); INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 3, 20); INSERT INTO confalias (confid, alias) VALUES (3, 'Test'); diff --git a/ui/amcontext.go b/ui/amcontext.go index 41d7d2e..1d911a2 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -50,6 +50,7 @@ type AmContext interface { RC() int OutputType() string Parameter(string) string + QueryParamInt(string, int) int RemoteIP() string ReplaceUser(*database.User) SaveSession() error @@ -241,6 +242,19 @@ func (c *amContext) Parameter(name string) string { return rc } +// QueryParamInt returns the value of a query parameter as an integer, with a default. +func (c *amContext) QueryParamInt(name string, defval int) int { + s := c.echoContext.QueryParam(name) + if s == "" { + return defval + } + rc, err := strconv.Atoi(s) + if err != nil { + return defval + } + return rc +} + // RemoteIP returns the remote IP address. func (c *amContext) RemoteIP() string { return c.echoContext.RealIP() diff --git a/ui/templates.go b/ui/templates.go index 0dc471d..0a67a5d 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -96,6 +96,15 @@ func makeYearRange(a jet.Arguments) reflect.Value { } } +func immediateIf(a jet.Arguments) reflect.Value { + cond := a.Get(0).Convert(reflect.TypeFor[bool]()).Bool() + if cond { + return a.Get(1) + } else { + return a.Get(2) + } +} + // extractCommunityLogo extracts a community logo URL from a community. func extractCommunityLogo(a jet.Arguments) reflect.Value { rc := "/img/builtin/default-community.jpg" @@ -189,6 +198,7 @@ func SetupTemplates() { views.AddGlobal("AmsterdamVersion", config.AMSTERDAM_VERSION) views.AddGlobal("AmsterdamCopyright", config.AMSTERDAM_COPYRIGHT) views.AddGlobal("GlobalConfig", config.GlobalConfig) + views.AddGlobalFunc("iif", immediateIf) views.AddGlobalFunc("MakeIntRange", makeIntRange) views.AddGlobalFunc("MakeYearRange", makeYearRange) views.AddGlobalFunc("ExtractCommunityLogo", extractCommunityLogo) diff --git a/ui/views/conflist.jet b/ui/views/conflist.jet index 69710d0..a183fdb 100644 --- a/ui/views/conflist.jet +++ b/ui/views/conflist.jet @@ -23,7 +23,7 @@ 🟣
- {{ c.Name }} - Latest activity: {{ DisplayActivity(c.LastUpdate, .) }}
diff --git a/ui/views/topiclist.jet b/ui/views/topiclist.jet new file mode 100644 index 0000000..c53df9f --- /dev/null +++ b/ui/views/topiclist.jet @@ -0,0 +1,143 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} +
+ +
+

Topics in {{ conferenceName }}

+
+
+ + + + + +
+
+ + + + {{ sp := iif(sort == 1, -1, 1) }} + + {{ sp = iif(sort == 2, -2, 2) }} + + {{ sp = iif(sort == 3, -3, 3) }} + + {{ sp = iif(sort == 4, -4, 4) }} + + {{ sp = iif(sort == 5, -5, 5) }} + + + + + {{ range i, t := topics }} + + + + + + + + {{ end }} + +
+ # + + Topic Name + + New + + Total + + Last Response +
+ {{ t.Number }} + + {{ t.Name }} + + {{ t.Unread }} + + {{ t.Total }} + + {{ formattedDate[i] }} +
+
+ + +
+
+ [ + {{ if view == 1 }} + New + {{ else }} + New + {{ end }} + | + {{ if view == 2 }} + Active + {{ else }} + Active + {{ end }} + | + {{ if view == 3 }} + All + {{ else }} + All + {{ end }} + | + {{ if view == 4 }} + Hidden + {{ else }} + Hidden + {{ end }} + | + {{ if view == 5 }} + Archived + {{ else }} + Archived + {{ end }} + ] +
+
+
+