diff --git a/NOTES.md b/NOTES.md index 416091da9a4869c3909be5de12c0e0c78140f1df..f6ce8a1a3cf06b2e7986e387fa5298de18a38cf9 100644 --- a/NOTES.md +++ b/NOTES.md @@ -21,7 +21,7 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented ## Things to do * Run [godoc](http://localhost:6060/) and then review where the help text is lacking. - * Missing [MAIL] [RESPOND] [SET DEFAULT_EXPIRE] [SET READNEW] [SET NOREADNEW] [SET NOSHOWNEW] [SET NOPROMPT_EXPIRE] [SET EXPIRE_LIMIT] [SET PROMPT_EXPIRE] [SET SHOWNEW] [SHOW NEW] [SEARCH] + * Missing [RESPOND] [MAIL] [SET PROMPT_EXPIRE] [SET NOREADNEW] [SET NOSHOWNEW] [SET EXPIRE_LIMIT] [SET NOPROMPT_EXPIRE] [SET READNEW] [SET SHOWNEW] [SET DEFAULT_EXPIRE] [SHOW NEW] * Run this.Skew.Safe() before... each command? each write? * Handle broadcast messages - create a broadcast table and add an expiration column. * Database @@ -30,6 +30,9 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented * Commands for a local mail system? * Commands to connect to Mattermost or mastodon? * Make a spreadsheet for signups. + * Pager: + * Make / work for search. + * Run [VACUUM](https://www.sqlite.org/lang_vacuum.html) on the expire run. Polishing. Review each command and put a + next to each as it is fully done. @@ -40,8 +43,8 @@ Top level: ADD +BACK BULLETIN CHANGE COPY CREATE +Ctrl-C +CURRENT DELETE DIRECTORY +EXIT +FIRST +Folders FORWARD +HELP INDEX +Keypad +LAST - MAIL +MARK MODIFY MOVE +NEXT PRINT - +QUIT +READ REMOVE +REPLY RESPOND SEARCH + MAIL +MARK MODIFY MOVE +NEXT +PRINT + +QUIT +READ REMOVE +REPLY RESPOND +SEARCH SEEN SELECT +SET +SHOW +SSH +UNMARK UNSEEN +USER diff --git a/folders/folders.go b/folders/folders.go index 7f2245ceeddda13656ef8e1a05bb9532120e4d37..ed5cae86fc1f0cd53066f06394cef3e6d0e8e625 100644 --- a/folders/folders.go +++ b/folders/folders.go @@ -90,6 +90,10 @@ func FindFolder(name string) storage.Folder { // IsFolderAccess checks if a user can access a folder. func IsFolderAccess(name, login string) bool { ctx := storage.Context() + admin, _ := this.Q.IsUserAdmin(ctx, login) + if admin == 1 { + return true + } found, _ := this.Q.IsFolderAccess(ctx, storage.IsFolderAccessParams{ Name: name, Login: login, diff --git a/repl/command.go b/repl/command.go index 43ee9340e5961bb89b3414602d5fdea03f6bd32c..7ed1ccb8d956005407f9cfa5ee62db97d2a3ef52 100644 --- a/repl/command.go +++ b/repl/command.go @@ -916,8 +916,9 @@ header. If a "search-string" is not specified, a search is made using the previously specified string, starting with the message following the one you are currently reading (or have just read). Once started, a search can be aborted by typing a CTRL-C.`, - MinArgs: 1, + MinArgs: 0, MaxArgs: 1, + Action: ActionSearch, Flags: dclish.Flags{ "/FOLDER": { Description: `/FOLDER=(folder,[...]) diff --git a/repl/messages.go b/repl/messages.go index ff2611fa55549dfdd6e779aec0794cb9d1923a7b..a0ffe012c4605845dca3464f9d97be164defd33a 100644 --- a/repl/messages.go +++ b/repl/messages.go @@ -13,6 +13,7 @@ import ( "git.lyda.ie/kevin/bulletin/editor" "git.lyda.ie/kevin/bulletin/folders" "git.lyda.ie/kevin/bulletin/pager" + "git.lyda.ie/kevin/bulletin/storage" "git.lyda.ie/kevin/bulletin/this" ) @@ -101,7 +102,6 @@ func ActionAdd(cmd *dclish.Command) error { optExtract = 1 } if cmd.Flags["/FOLDER"].Value != "" { - fmt.Printf("/FOLDER = %s\n", cmd.Flags["/FOLDER"].Value) optFolder = strings.Split(cmd.Flags["/FOLDER"].Value, ",") } if cmd.Flags["/INDENT"].Value == "true" { @@ -253,14 +253,24 @@ func ActionNext(_ *dclish.Command) error { // ActionPrint handles the `PRINT` command. func ActionPrint(cmd *dclish.Command) error { - // TODO: handle flags. + all := false + if cmd.Flags["/ALL"].Value == "true" { + all = true + } + + ctx := storage.Context() msgids := []int64{this.MsgID} + var err error if len(cmd.Args) == 1 { - var err error + if all { + return errors.New("Can't provide a message list and /ALL") + } msgids, err = ParseNumberList(cmd.Args[0]) if err != nil { return err } + } else if all { + msgids, err = this.Q.ListMessageIDs(ctx, this.Folder.Name) } print("\033[5i") for _, msgid := range msgids { @@ -453,3 +463,159 @@ func ActionUnmark(cmd *dclish.Command) error { } return nil } + +// ActionSearch handles the `SEARCH` command. This will show all messages +// matching a search term. +// +// See subtoutines SEARCH and GET_SEARCH in bulletin2.for for the +// original implementation. +func ActionSearch(cmd *dclish.Command) error { + ctx := storage.Context() + var err error + optFolders := []string{this.Folder.Name} + if cmd.Flags["/FOLDER"].Value != "" { + optFolders = strings.Split(strings.ToUpper(cmd.Flags["/FOLDER"].Value), ",") + for i := range optFolders { + folder, _ := this.Q.FindFolderExact(ctx, optFolders[i]) + if folder.Name != "" { + return fmt.Errorf("Folder '%s' not found", optFolders[i]) + } + if folders.IsFolderAccess(optFolders[i], this.User.Login) { + return fmt.Errorf("Folder '%s' is not accessible", optFolders[i]) + } + } + } + + optReply := false + if cmd.Flags["/REPLY"].Value == "true" { + optReply = true + } + + optReverse := false + if cmd.Flags["/REVERSE"].Value == "true" { + optReverse = true + } + + optStart := int64(-1) // -1 means first message. + if optReverse { + optStart = 0 // 0 means last message. + } + if cmd.Flags["/START"].Set { + optStart, err = strconv.ParseInt(cmd.Flags["/START"].Value, 10, 64) + if err != nil { + return err + } + if optStart < 1 { + return errors.New("/START must be 1 or larger") + } + } + + optSubject := false + if cmd.Flags["/SUBJECT"].Value == "true" { + optSubject = true + } + + var searchTerm string + if optReply { + if optSubject || len(cmd.Args) == 1 { + return errors.New("Can't specify /REPLY and a search term or /SUBJECT") + } + msg, err := this.Q.ReadMessage(ctx, storage.ReadMessageParams{ + Folder: this.Folder.Name, + ID: this.MsgID, + }) + if err != nil { + return err + } + searchTerm = "Re: " + msg.Subject + } else { + searchTerm = cmd.Args[0] + } + + allMsgs := []storage.Message{} + msgs := []storage.Message{} + var start int64 + for i := range optFolders { + switch optStart { + case -1: + start = 1 + case 0: + start, err := this.Q.LastMsgidIgnoringSeen(ctx, optFolders[i]) + if err != nil || start == 0 { + continue + } + default: + start = optStart + } + if optReply { + if optReverse { + msgs, err = this.Q.SearchReplyReverse(ctx, + storage.SearchReplyReverseParams{ + Subject: searchTerm, + ID: start, + Folder: optFolders[i], + }) + } else { + msgs, err = this.Q.SearchReply(ctx, + storage.SearchReplyParams{ + Subject: searchTerm, + ID: start, + Folder: optFolders[i], + }) + } + } else if optSubject { + if optReverse { + msgs, err = this.Q.SearchSubjectReverse(ctx, + storage.SearchSubjectReverseParams{ + Column1: nullStr(searchTerm), + ID: start, + Folder: optFolders[i], + }) + } else { + msgs, err = this.Q.SearchSubject(ctx, + storage.SearchSubjectParams{ + Column1: nullStr(searchTerm), + ID: start, + Folder: optFolders[i], + }) + } + } else { + if optReverse { + msgs, err = this.Q.SearchReverse(ctx, + storage.SearchReverseParams{ + Column1: nullStr(searchTerm), + ID: start, + Folder: optFolders[i], + }) + } else { + msgs, err = this.Q.Search(ctx, + storage.SearchParams{ + Column1: nullStr(searchTerm), + ID: start, + Folder: optFolders[i], + }) + } + } + if err != nil { + continue + } + if len(allMsgs)+len(msgs) > 100 { + fmt.Println("Too many messages match; narrow search term.") + return nil + } + allMsgs = append(allMsgs, msgs...) + } + + if len(allMsgs) == 0 { + fmt.Println("No messages found.") + return nil + } + buf := strings.Builder{} + for _, msg := range allMsgs { + buf.WriteString(msg.String()) + buf.WriteString("\n\n") + } + pager.Pager(buf.String()) + + return nil +} diff --git a/repl/storage.go b/repl/storage.go new file mode 100644 index 0000000000000000000000000000000000000000..10d8378bed27646b94a8778853391595b2c81bf8 --- /dev/null +++ b/repl/storage.go @@ -0,0 +1,10 @@ +package repl + +import "database/sql" + +func nullStr(s string) sql.NullString { + return sql.NullString{ + String: s, + Valid: true, + } +} diff --git a/storage/messages.sql.go b/storage/messages.sql.go index d804c873c029be4111beea24076c144d5af24a42..5a5fb40c24c50ea3bad2d6b292a3afe920827895 100644 --- a/storage/messages.sql.go +++ b/storage/messages.sql.go @@ -7,6 +7,7 @@ package storage import ( "context" + "database/sql" "time" ) @@ -52,9 +53,9 @@ func (q *Queries) DeleteAllMessages(ctx context.Context, folder string) error { const getLastRead = `-- name: GetLastRead :many SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author -GROUP BY m.author -ORDER BY m.author + WHERE folder = ? AND u.login == m.author + GROUP BY m.author + ORDER BY m.author ` type GetLastReadRow struct { @@ -88,9 +89,9 @@ func (q *Queries) GetLastRead(ctx context.Context, folder string) ([]GetLastRead const getLastReadByEnabled = `-- name: GetLastReadByEnabled :many SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author AND u.disabled = ? -GROUP BY m.author -ORDER BY m.author + WHERE folder = ? AND u.login == m.author AND u.disabled = ? + GROUP BY m.author + ORDER BY m.author ` type GetLastReadByEnabledParams struct { @@ -128,7 +129,7 @@ func (q *Queries) GetLastReadByEnabled(ctx context.Context, arg GetLastReadByEna const getLastReadByUser = `-- name: GetLastReadByUser :one SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author AND m.author = ? + WHERE folder = ? AND u.login == m.author AND m.author = ? ` type GetLastReadByUserParams struct { @@ -258,6 +259,293 @@ func (q *Queries) ReadMessage(ctx context.Context, arg ReadMessageParams) (Messa return i, err } +const search = `-- name: Search :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE (message LIKE '%' || ?1 || '%' + OR subject LIKE '%' || ?1 || '%') + AND id >= ?2 + AND folder = ?3 +` + +type SearchParams struct { + Column1 sql.NullString + ID int64 + Folder string +} + +func (q *Queries) Search(ctx context.Context, arg SearchParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, search, arg.Column1, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchReply = `-- name: SearchReply :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE subject = ? + AND id >= ? + AND folder = ? +` + +type SearchReplyParams struct { + Subject string + ID int64 + Folder string +} + +func (q *Queries) SearchReply(ctx context.Context, arg SearchReplyParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, searchReply, arg.Subject, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchReplyReverse = `-- name: SearchReplyReverse :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE subject = ? + AND id <= ? + AND folder = ? + ORDER BY DESC +` + +type SearchReplyReverseParams struct { + Subject string + ID int64 + Folder string +} + +func (q *Queries) SearchReplyReverse(ctx context.Context, arg SearchReplyReverseParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, searchReplyReverse, arg.Subject, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchReverse = `-- name: SearchReverse :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE (message LIKE '%' || ?1 || '%' + OR subject LIKE '%' || ?1 || '%') + AND id <= ?2 + AND folder = ?3 + ORDER BY DESC +` + +type SearchReverseParams struct { + Column1 sql.NullString + ID int64 + Folder string +} + +func (q *Queries) SearchReverse(ctx context.Context, arg SearchReverseParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, searchReverse, arg.Column1, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchSubject = `-- name: SearchSubject :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE subject LIKE '%' || ? || '%' + AND id >= ? + AND folder = ? +` + +type SearchSubjectParams struct { + Column1 sql.NullString + ID int64 + Folder string +} + +func (q *Queries) SearchSubject(ctx context.Context, arg SearchSubjectParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, searchSubject, arg.Column1, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchSubjectReverse = `-- name: SearchSubjectReverse :many +SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages + WHERE subject LIKE '%' || ? || '%' + AND id <= ? + AND folder = ? + ORDER BY DESC +` + +type SearchSubjectReverseParams struct { + Column1 sql.NullString + ID int64 + Folder string +} + +func (q *Queries) SearchSubjectReverse(ctx context.Context, arg SearchSubjectReverseParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, searchSubjectReverse, arg.Column1, arg.ID, arg.Folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Folder, + &i.Author, + &i.Subject, + &i.Message, + &i.Permanent, + &i.Shutdown, + &i.Expiration, + &i.CreateAt, + &i.UpdateAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setMessageSeen = `-- name: SetMessageSeen :exec INSERT INTO seen (login, folder, msgid) VALUES (?, ?, ?) ` diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql index 46dfee3deadfdb098d29d47f3c288e045e1d5d33..acec6f82f018420ea63ef35cbb93fce6db95f451 100644 --- a/storage/migrations/1_create_table.up.sql +++ b/storage/migrations/1_create_table.up.sql @@ -3,7 +3,7 @@ CREATE TABLE users ( name VARCHAR(53) NOT NULL, admin INT DEFAULT 0 NOT NULL, moderator INT DEFAULT 0 NOT NULL, - alert INT NOT NULL DEFAULT 0, --- 0=no, 1=brief, 2=readnew + alert INT DEFAULT 0 NOT NULL, --- 0=no, 1=brief, 2=readnew disabled INT DEFAULT 0 NOT NULL, last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql index d6f48c654c0157869d4a1a7012b61d6eeed1cd1d..901d71c0e471aebc4f27552895b7e7a6b3df4dd6 100644 --- a/storage/queries/messages.sql +++ b/storage/queries/messages.sql @@ -41,16 +41,57 @@ SELECT CAST(MAX(id) AS INT) FROM messages AS m WHERE m.folder = ?1; --- TODO: These get the max message written, not read. Leaving for now; easier to test. -- name: GetLastRead :many SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author -GROUP BY m.author -ORDER BY m.author; + WHERE folder = ? AND u.login == m.author + GROUP BY m.author + ORDER BY m.author; -- name: GetLastReadByEnabled :many SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author AND u.disabled = ? -GROUP BY m.author -ORDER BY m.author; + WHERE folder = ? AND u.login == m.author AND u.disabled = ? + GROUP BY m.author + ORDER BY m.author; -- name: GetLastReadByUser :one SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u -WHERE folder = ? AND u.login == m.author AND m.author = ?; + WHERE folder = ? AND u.login == m.author AND m.author = ?; + +-- name: Search :many +SELECT * FROM messages + WHERE (message LIKE '%' || ?1 || '%' + OR subject LIKE '%' || ?1 || '%') + AND id >= ?2 + AND folder = ?3; + +-- name: SearchReverse :many +SELECT * FROM messages + WHERE (message LIKE '%' || ?1 || '%' + OR subject LIKE '%' || ?1 || '%') + AND id <= ?2 + AND folder = ?3 + ORDER BY DESC; + +-- name: SearchSubject :many +SELECT * FROM messages + WHERE subject LIKE '%' || ? || '%' + AND id >= ? + AND folder = ?; + +-- name: SearchSubjectReverse :many +SELECT * FROM messages + WHERE subject LIKE '%' || ? || '%' + AND id <= ? + AND folder = ? + ORDER BY DESC; + +-- name: SearchReply :many +SELECT * FROM messages + WHERE subject = ? + AND id >= ? + AND folder = ?; + +-- name: SearchReplyReverse :many +SELECT * FROM messages + WHERE subject = ? + AND id <= ? + AND folder = ? + ORDER BY DESC; diff --git a/storage/queries/standard.sql b/storage/queries/standard.sql index cff89bb0f1d82c98ae4510513db2397f47a73b04..bd37e86e8fef6674b656494ed03027088f59e6ea 100644 --- a/storage/queries/standard.sql +++ b/storage/queries/standard.sql @@ -37,6 +37,9 @@ INSERT INTO messages (id, folder) VALUES (?, ?); -- name: ListMessages :many SELECT * FROM messages WHERE folder = ? ORDER BY id; +-- name: ListMessageIDs :many +SELECT id FROM messages WHERE folder = ? ORDER BY id; + -- name: GetMessage :one SELECT * FROM messages WHERE id = ? AND folder = ?; diff --git a/storage/standard.sql.go b/storage/standard.sql.go index 69f0f8f40daa617c9ac334bc6348fb51d197d827..f6c4615c285fbb883b1ae91d779314aa26cc9bb4 100644 --- a/storage/standard.sql.go +++ b/storage/standard.sql.go @@ -530,6 +530,33 @@ func (q *Queries) ListMark(ctx context.Context, arg ListMarkParams) ([]Mark, err return items, nil } +const listMessageIDs = `-- name: ListMessageIDs :many +SELECT id FROM messages WHERE folder = ? ORDER BY id +` + +func (q *Queries) ListMessageIDs(ctx context.Context, folder string) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listMessageIDs, folder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMessages = `-- name: ListMessages :many SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages WHERE folder = ? ORDER BY id `