diff --git a/database/post.go b/database/post.go index 4e5aa53..7adc9fe 100644 --- a/database/post.go +++ b/database/post.go @@ -20,6 +20,7 @@ import ( "time" "git.erbosoft.com/amy/amsterdam/config" + log "github.com/sirupsen/logrus" ) // PostHeader represents the "header" of a post, everything except for its text and attachment. @@ -718,3 +719,241 @@ func AmGetPublishedPosts(ctx context.Context) ([]*PostHeader, error) { return rc, nil } + +type PostSearchResult struct { + PostLink string + Author string + PostDate time.Time + Lines int32 + Excerpt string +} + +const EXCERPT_MAX = 60 // temporary implementation + +// decodeSearchScope turns the scope values from the AmSearchPosts call into a set of coherent values. +func decodeSearchScope(ctx context.Context, scopeValues []any) (string, *Community, *Conference, *Topic, error) { + var myComm *Community = nil + var myConf *Conference = nil + var myTopic *Topic = nil + + // Sort the items in the scopeValues array and fill them in the right slots. + for i := range scopeValues { + if scopeValues[i] == nil { + continue + } + if thisComm, ok := scopeValues[i].(*Community); ok { + if myComm != nil { + return "error", nil, nil, nil, errors.New("cannot specify multiple communities") + } + myComm = thisComm + continue + } + if thisConf, ok := scopeValues[i].(*Conference); ok { + if myConf != nil { + return "error", nil, nil, nil, errors.New("cannot specify multiple conferences") + } + myConf = thisConf + continue + } + if thisTopic, ok := scopeValues[i].(*Topic); ok { + if myTopic != nil { + return "error", nil, nil, nil, errors.New("cannot specify multiple topics") + } + myTopic = thisTopic + continue + } + return "error", nil, nil, nil, errors.New("invalid item specified in scope") + } + + // Based on which slots are full, determine the scope. Also error-check relations between the specified slots. + if myComm == nil { + if myConf != nil || myTopic != nil { + return "error", nil, nil, nil, errors.New("conference/topic specified without community") + } + return "global", nil, nil, nil, nil + } + if myConf == nil { + if myTopic != nil { + return "error", nil, nil, nil, errors.New("topic specified without conference") + } + return "community", myComm, nil, nil, nil + } + f, err := myConf.InCommunity(ctx, myComm) + if err != nil { + return "error", nil, nil, nil, err + } + if !f { + return "error", nil, nil, nil, errors.New("community does not contain conference") + } + if myTopic == nil { + return "conference", myComm, myConf, nil, nil + } + if myTopic.ConfId != myConf.ConfId { + return "error", nil, nil, nil, errors.New("conference does not contain topic") + } + return "topic", myComm, myConf, myTopic, nil +} + +/* AmSearchPosts finds posts by using full text search on their contents. + * Parameters: + * ctx - Standard Go context value. + * searchTerms - The terms to search for in the text. + * u - The user performing the search. + * offset - How many posts in the results to skip. + * max - Maximum number of posts to return. + * scopeValues - Multiple object values to limit the scope. Put a Community pointer here to limit the scope to + * that community. Also add a Conference pointer (from that community) to limit the scope to that conference. + * Also add a Topic pointer (from that conference) to limit the scope to that topic. + * Returns: + * Array of PostSearchResult structures with the results. + * Total number of posts that match the search. + * Standard Go error status. + */ +func AmSearchPosts(ctx context.Context, searchTerms string, u *User, offset, max int, scopeValues ...any) ([]PostSearchResult, int, error) { + // Decode the search scope. + scope, comm, conf, topic, err := decodeSearchScope(ctx, scopeValues) + if err != nil { + return nil, -1, err + } + + // Get the proper service index to match against the community services. + confService, err := AmGetServiceIndex("community", "Conference") + if err != nil { + return nil, -1, err + } + + // Get the count of matching posts. + var row *sql.Row + switch scope { + case "global": + row = amdb.QueryRowContext(ctx, `SELECT COUNT(*) + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?)`, u.Uid, confService, searchTerms) + case "community": + row = amdb.QueryRowContext(ctx, `SELECT COUNT(*) + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?)`, u.Uid, confService, comm.Id, searchTerms) + case "conference": + row = amdb.QueryRowContext(ctx, `SELECT COUNT(*) + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND c.confid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?)`, u.Uid, confService, comm.Id, conf.ConfId, searchTerms) + case "topic": + row = amdb.QueryRowContext(ctx, `SELECT COUNT(*) + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND c.confid = ? AND t.topicid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?)`, + u.Uid, confService, comm.Id, conf.ConfId, topic.TopicId, searchTerms) + } + var count int + err = row.Scan(&count) + if err != nil { + log.Errorf("AmSearchPosts query 1 error %v", err) + return nil, -1, err + } + + // Get the matching posts themselves. + var rs *sql.Rows + switch scope { + case "global": + rs, err = amdb.QueryContext(ctx, `SELECT q.commid, q.alias, c.confid, t.topicid, t.num, p.postid, p.num, u2.username, p.posted, p.linecount, d.data + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?) ORDER BY q.commname, c.name, t.num, p.num + LIMIT ? OFFSET ?`, u.Uid, confService, searchTerms, max, offset) + case "community": + rs, err = amdb.QueryContext(ctx, `SELECT q.commid, q.alias, c.confid, t.topicid, t.num, p.postid, p.num, u2.username, p.posted, p.linecount, d.data + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?) ORDER BY q.commname, c.name, t.num, p.num + LIMIT ? OFFSET ?`, u.Uid, confService, comm.Id, searchTerms, max, offset) + case "conference": + rs, err = amdb.QueryContext(ctx, `SELECT q.commid, q.alias, c.confid, t.topicid, t.num, p.postid, p.num, u2.username, p.posted, p.linecount, d.data + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND c.confid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?) ORDER BY q.commname, c.name, t.num, p.num + LIMIT ? OFFSET ?`, u.Uid, confService, comm.Id, conf.ConfId, searchTerms, max, offset) + case "topic": + rs, err = amdb.QueryContext(ctx, `SELECT q.commid, q.alias, c.confid, t.topicid, t.num, p.postid, p.num, u2.username, p.posted, p.linecount, d.data + FROM communities q JOIN commtoconf s ON s.commid = q.commid JOIN confs c ON c.confid = s.confid + JOIN commmember m ON m.commid = q.commid JOIN users u ON u.uid = m.uid JOIN commftrs f ON f.commid = q.commid + JOIN topics t ON t.confid = c.confid JOIN posts p ON p.topicid = t.topicid JOIN postdata d ON d.postid = p.postid JOIN users u2 ON u2.uid = p.creator_uid + LEFT JOIN confmember x ON (c.confid = x.confid AND u.uid = x.uid) + WHERE u.uid = ? AND f.ftr_code = ? AND GREATEST(u.base_lvl,m.granted_lvl,s.granted_lvl,IFNULL(x.granted_lvl,0)) >= c.read_lvl + AND q.commid = ? AND c.confid = ? AND t.topicid = ? AND p.scribble_uid IS NULL AND MATCH(d.data) AGAINST (?) ORDER BY q.commname, c.name, t.num, p.num + LIMIT ? OFFSET ?`, u.Uid, confService, comm.Id, conf.ConfId, topic.TopicId, searchTerms, max, offset) + } + if err != nil { + log.Errorf("AmSearchPosts query 2 error %v", err) + return nil, count, err + } + rc := make([]PostSearchResult, max) + i := 0 + for rs.Next() { + var commid int32 + var commAlias string + var confid int32 + var topicid int32 + var topicNum int16 + var postid int64 + var postnum int32 + err := rs.Scan(&commid, &commAlias, &confid, &topicid, &topicNum, &postid, &postnum, &(rc[i].Author), &(rc[i].PostDate), + &(rc[i].Lines), &(rc[i].Excerpt)) + if err != nil { + return nil, count, err + } + + // Get conference so we can get aliases. + conf, err := AmGetConference(ctx, confid) + if err != nil { + return nil, count, err + } + alias, err := conf.Aliases(ctx) + if err != nil { + return nil, count, err + } + + // Build the post link. + plink := AmCreatePostLinkContext(commAlias, alias[0], topicNum) + plink.FirstPost = postnum + plink.LastPost = postnum + rc[i].PostLink = plink.AsString() + + // Trim down the excerpt. + if len(rc[i].Excerpt) > EXCERPT_MAX { + choplen := min(len(rc[i].Excerpt), EXCERPT_MAX*3) + tmp := []rune(rc[i].Excerpt[:choplen]) + choplen = min(len(tmp), EXCERPT_MAX) + rc[i].Excerpt = fmt.Sprintf("%s...", string(tmp[:choplen])) + } + i++ // go on to the next + } + + if i < max { + rc = rc[:i] // slice off any empty entries at the end + } + return rc, count, nil +} diff --git a/database/services.go b/database/services.go index 7341a5c..240e153 100644 --- a/database/services.go +++ b/database/services.go @@ -12,6 +12,7 @@ package database import ( "context" _ "embed" + "errors" "slices" "sync" @@ -123,6 +124,23 @@ func init() { } } +/* AmGetServiceIndex returns the service index for the given service by domain and identifier. + * Parameters: + * domain - The domain of the service to look for. + * id - The identifier of the service. + * Returns: + * The service index, if the service is found. + * Standard Go error status. + */ +func AmGetServiceIndex(domain, id string) (int16, error) { + if d, ok := serviceRoot.byName[domain]; ok { + if svc, ok2 := d.byId[id]; ok2 { + return svc.Index, nil + } + } + return -1, errors.New("service not found") +} + /* AmGetCommunityServices returns all the community service definitions for a community. * Parameters: * ctx - Standard Go context value. diff --git a/docs/MISSINGFUNCS.md b/docs/MISSINGFUNCS.md index 733079e..54ff87d 100644 --- a/docs/MISSINGFUNCS.md +++ b/docs/MISSINGFUNCS.md @@ -5,7 +5,7 @@ _(italicized items can be deferred)_ - ~~Topics list: Set up Conference permalink~~ - ~~Send out E-mails to topic subscribers when a post is made~~ - _Error handling: shift titles and templates for different error codes_ -- Find Posts +- ~~Find Posts~~ - Services mechanism: Conference vtable - ~~User creation: copy conference hotlists ~~ - _Calendar (top menu link)_ @@ -18,6 +18,8 @@ _(italicized items can be deferred)_ - User Account Management - System Audit Logs - Import User Accounts +- Conferences list: + - Find - Community Admin Menu: - Set Community Category - Set Community Services diff --git a/find.go b/find.go index e153520..bbc6a42 100644 --- a/find.go +++ b/find.go @@ -279,7 +279,12 @@ func Find(ctxt ui.AmContext) (string, any, error) { } } case "PST": - // TODO + var postlist []database.PostSearchResult + postlist, total, err = database.AmSearchPosts(ctxt.Ctx(), term, ctxt.CurrentUser(), ofs*listMax, listMax) + if err == nil { + numResults = len(postlist) + ctxt.VarMap().Set("resultList", postlist) + } } if err != nil { ctxt.VarMap().Set("errorMessage", err.Error()) diff --git a/ui/views/find.jet b/ui/views/find.jet index e95e33d..25e154d 100644 --- a/ui/views/find.jet +++ b/ui/views/find.jet @@ -218,22 +218,14 @@ {{ range _, rx := resultList }}