landed AmSearchPosts and the "Find Posts" functionality on the main Find page

This commit is contained in:
2026-01-31 22:32:40 -07:00
parent d621eb07c0
commit 38ed57d207
5 changed files with 272 additions and 16 deletions
+239
View File
@@ -20,6 +20,7 @@ import (
"time" "time"
"git.erbosoft.com/amy/amsterdam/config" "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. // 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 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
}
+18
View File
@@ -12,6 +12,7 @@ package database
import ( import (
"context" "context"
_ "embed" _ "embed"
"errors"
"slices" "slices"
"sync" "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. /* AmGetCommunityServices returns all the community service definitions for a community.
* Parameters: * Parameters:
* ctx - Standard Go context value. * ctx - Standard Go context value.
+3 -1
View File
@@ -5,7 +5,7 @@ _(italicized items can be deferred)_
- ~~Topics list: Set up Conference permalink~~ - ~~Topics list: Set up Conference permalink~~
- ~~Send out E-mails to topic subscribers when a post is made~~ - ~~Send out E-mails to topic subscribers when a post is made~~
- _Error handling: shift titles and templates for different error codes_ - _Error handling: shift titles and templates for different error codes_
- Find Posts - ~~Find Posts~~
- Services mechanism: Conference vtable - Services mechanism: Conference vtable
- ~~User creation: copy conference hotlists ~~ - ~~User creation: copy conference hotlists ~~
- _Calendar (top menu link)_ - _Calendar (top menu link)_
@@ -18,6 +18,8 @@ _(italicized items can be deferred)_
- User Account Management - User Account Management
- System Audit Logs - System Audit Logs
- Import User Accounts - Import User Accounts
- Conferences list:
- Find
- Community Admin Menu: - Community Admin Menu:
- Set Community Category - Set Community Category
- Set Community Services - Set Community Services
+6 -1
View File
@@ -279,7 +279,12 @@ func Find(ctxt ui.AmContext) (string, any, error) {
} }
} }
case "PST": 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 { if err != nil {
ctxt.VarMap().Set("errorMessage", err.Error()) ctxt.VarMap().Set("errorMessage", err.Error())
+6 -14
View File
@@ -218,22 +218,14 @@
{{ range _, rx := resultList }} {{ range _, rx := resultList }}
<tr class="hover:bg-blue-50"> <tr class="hover:bg-blue-50">
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<a href="http://necrovenice:8080/venice/go/minds!Playground.2.880" <a href="/go/{{ rx.PostLink }}" class="text-blue-700 hover:text-blue-900 font-mono">{{ rx.PostLink }}</a>
class="text-blue-700 hover:text-blue-900 font-mono">minds!Playground.2.880</a>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<a href="http://necrovenice:8080/venice/user/Beenherebefo" <a href="/user/{{ rx.Author }}" class="text-blue-700 hover:text-blue-900">{{ rx.Author }}</a>
class="text-blue-700 hover:text-blue-900">Beenherebefo</a>
</td>
<td class="px-4 py-3 text-sm whitespace-nowrap text-gray-600">
Jan 1, 2002 10:57:06 PM
</td>
<td class="px-4 py-3 text-sm text-gray-600">
12
</td>
<td class="px-4 py-3 text-sm text-gray-600 italic">
Bold, the truth is that one entity can incorporate in only...
</td> </td>
<td class="px-4 py-3 text-sm whitespace-nowrap text-gray-600">{{ DisplayDateTime( rx.PostDate, .) }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ rx.Lines }}</td>
<td class="px-4 py-3 text-sm text-gray-600 italic">{{ rx.Excerpt }}</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>
@@ -241,7 +233,7 @@
</div> </div>
</div> </div>
{{ if isset(resultShowPrev) || isset(resuiltShowNext) }} {{ if isset(resultShowPrev) || isset(resultShowNext) }}
<!-- Bottom Navigation --> <!-- Bottom Navigation -->
<div class="flex justify-end mt-6"> <div class="flex justify-end mt-6">
<form method="POST" action="/find"> <form method="POST" action="/find">