diff --git a/NOTES.md b/NOTES.md index 15e3277de66fe844ef30af6fd84b4ed5719f53dc..16aa923e155942dfdd07ac96fdfcde6441b590f0 100644 --- a/NOTES.md +++ b/NOTES.md @@ -29,7 +29,7 @@ repl.commands? * Implement each command. * Next: folder commands - ~~CREATE~~, ~~REMOVE~~, MODIFY, ~~INDEX~~, ~~SELECT~~ - * Messages: ~~ADD~~, CURRENT, DIRECTORY, BACK, CHANGE, FIRST, REMOVE, NEXT, READ + * Messages: ~~ADD~~, CURRENT, ~~DIRECTORY~~, BACK, CHANGE, FIRST, REMOVE, NEXT, ~~READ~~ * Messages edit: CHANGE, REPLY, FORWARD * Moving messages: COPY, MOVE * Compound commands: SET and SHOW @@ -60,7 +60,8 @@ repl.commands? * Commands for a local mail system? * Commands to connect to Mattermost or mastodon? * Commands to manage users. - * `SHOW VERSION` - use [this](https://github.com/earthboundkid/versioninfo) + * `SHOW VERSION` - versioninfo doesn't work; what else? + * Check db version; notify user if it changes; refuse to write to db if it has. ## Module links diff --git a/dclish/dclish.go b/dclish/dclish.go index 0302781f228afc4fcd836574770c2a08d8d540eb..237f1978d6f0328c5241cf7d91aa803ea685b382 100644 --- a/dclish/dclish.go +++ b/dclish/dclish.go @@ -140,6 +140,10 @@ func (c Commands) ParseAndRun(line string) error { } cmd.Args = []string{} if len(words) == 1 { + if len(cmd.Args) < cmd.MinArgs { + fmt.Println("ERROR: Not enough args.") + return nil + } return cmd.Action(cmd) } // TODO: need to clean this up. diff --git a/editor/tview.go b/editor/tview.go index 541678df070270cc4140060d9df0b6bdee07641c..50f3ab9b747f5130dc8b41fb654580053a345935 100644 --- a/editor/tview.go +++ b/editor/tview.go @@ -10,6 +10,20 @@ import ( // Editor is the editor for text files. func Editor(placeholder, title string) (string, error) { + theme := tview.Theme{ + PrimitiveBackgroundColor: tcell.ColorDefault, + ContrastBackgroundColor: tcell.ColorDefault, + MoreContrastBackgroundColor: tcell.ColorDefault, + BorderColor: tcell.ColorDefault, + TitleColor: tcell.ColorDefault, + GraphicsColor: tcell.ColorDefault, + PrimaryTextColor: tcell.ColorDefault, + SecondaryTextColor: tcell.ColorDefault, + TertiaryTextColor: tcell.ColorDefault, + InverseTextColor: tcell.ColorDefault, + ContrastSecondaryTextColor: tcell.ColorDefault, + } + tview.Styles = theme app := tview.NewApplication() textArea := tview.NewTextArea(). diff --git a/folders/folders.go b/folders/folders.go index 04b69a13ca5d24aefecbe0dfa7284dc2689dbc09..811f10bcedbff5e49d9c60fd2f7c95d5ea0c8114 100644 --- a/folders/folders.go +++ b/folders/folders.go @@ -4,6 +4,7 @@ package folders import ( "errors" "fmt" + "strings" "github.com/jmoiron/sqlx" ) @@ -34,6 +35,10 @@ type FolderCreateOptions struct { // CreateFolder creates a new folder. func (s *Store) CreateFolder(name string, options FolderCreateOptions) error { + if !IsAlphaNum(name) { + return errors.New("Folder can only have letters and numbers") + } + name = strings.ToUpper(name) _, err := s.db.Exec( `INSERT INTO folders (name, always, brief, description, notify, owner, readnew, @@ -52,6 +57,7 @@ func (s *Store) CreateFolder(name string, options FolderCreateOptions) error { options.Expire, options.Visibility, ) + // TODO: turn _ into rows and make sure it was added. // TODO: process this error a bit more to give a better error message. return err } @@ -102,6 +108,19 @@ func (s *Store) ListFolder(user string, _ FolderListOptions) ([]FolderListRow, e return flr, nil } +// FindFolder finds a folder based on the prefix. +func (s *Store) FindFolder(name string) string { + var folder string + s.db.Get(&folder, "SELECT name FROM folders where name = $1", name) + if folder != "" { + return folder + } + s.db.Get(&folder, + `SELECT name FROM folders where name LIKE $1 + ORDER BY name LIMIT 1`, name+"%") + return folder +} + // IsFolderAccess checks if a user can access a folder. func (s *Store) IsFolderAccess(name, user string) bool { found := 0 diff --git a/folders/messages.go b/folders/messages.go index abb310626039466f52182ccf7d2b33720566934a..bd08ec9e7b1ee7a089f1f2f9cb70b2e61bccbdaa 100644 --- a/folders/messages.go +++ b/folders/messages.go @@ -2,6 +2,7 @@ package folders import ( + "errors" "fmt" "strings" "time" @@ -66,16 +67,45 @@ func (m *Message) String() string { // ReadMessage reads a message for a user. func (s *Store) ReadMessage(login, folder string, msgid int) (*Message, error) { msg := &Message{} - err := s.db.Get(msg, + s.db.Get(msg, `SELECT id, folder, author, subject, message, expiration, create_at, update_at - FROM messages WHERE folder = $1, id = $2`, folder, msgid) - if err != nil { - return nil, err + FROM messages WHERE folder = $1 AND id = $2`, folder, msgid) + if msg.ID != msgid || msgid == 0 { + return nil, errors.New("Specified message was not found") } // TODO: replace _ with rows and check. - _, err = s.db.Exec( + s.db.Exec( "INSERT INTO read (login, folder, msgid) VALUES ($1, $2, $3)", login, folder, msgid) - return msg, err + return msg, nil +} + +// ListMessagesOptions has the options for a ListMessages call. +type ListMessagesOptions struct { + // TODO: is this needed? Maybe for message order? +} + +// ListMessages lists messages. +func (s *Store) ListMessages(folder string, options *ListMessagesOptions) ([]Message, error) { + messages := []Message{} + if options != nil { + return messages, errors.New("TODO: options aren't implemented") + } + rows, err := s.db.Queryx( + `SELECT id, folder, author, subject, message, expiration, create_at, update_at + FROM messages WHERE folder = $1`, folder) + if err != nil { + return messages, nil + } + for rows.Next() { + msg := Message{} + err = rows.StructScan(&msg) + if err != nil { + return []Message{}, nil + } + messages = append(messages, msg) + } + return messages, nil + } diff --git a/folders/sql/1_create_table.down.sql b/folders/sql/1_create_table.down.sql index 4070921fe631b62d42878b713da994824eacb233..8a1a47efb2c60d66a1e51323a8ca98c71013d3e2 100644 --- a/folders/sql/1_create_table.down.sql +++ b/folders/sql/1_create_table.down.sql @@ -1,4 +1,11 @@ --- Dropped in reverse order to deal with foreign keys. +DROP TABLE mark; +DROP TABLE read; + +DROP INDEX messages_idx_expiration; +DROP INDEX messages_idx_shutdown; +DROP TABLE messages; + DROP TRIGGER co_owners_after_update_update_at; DROP TABLE co_owners; diff --git a/folders/sql/1_create_table.up.sql b/folders/sql/1_create_table.up.sql index 86631a20bf01f5baff868703dd5bf6badd3c1d9c..c8f55c2f0fe97672adcf357c6e56bc2f817c8b41 100644 --- a/folders/sql/1_create_table.up.sql +++ b/folders/sql/1_create_table.up.sql @@ -1,5 +1,5 @@ CREATE TABLE users ( - login VARCHAR(25) NOT NULL PRIMARY KEY, + login VARCHAR(12) NOT NULL PRIMARY KEY, name VARCHAR(53) NOT NULL, admin INT DEFAULT 0, disabled INT DEFAULT 0, @@ -122,7 +122,19 @@ CREATE TABLE read ( folder VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE, msgid INT, PRIMARY KEY (folder, login, msgid), - CONSTRAINT FK_id_folder + CONSTRAINT read_fk_id_folder + FOREIGN KEY (msgid, folder) + REFERENCES messages(id, folder) + ON DELETE CASCADE + ON UPDATE CASCADE +) WITHOUT ROWID; + +CREATE TABLE mark ( + login VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE, + folder VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE, + msgid INT, + PRIMARY KEY (folder, login, msgid), + CONSTRAINT mark_fk_id_folder FOREIGN KEY (msgid, folder) REFERENCES messages(id, folder) ON DELETE CASCADE diff --git a/folders/verify.go b/folders/verify.go new file mode 100644 index 0000000000000000000000000000000000000000..ab28a6ace05b2bbca1e9a3aec7be168ddd32f362 --- /dev/null +++ b/folders/verify.go @@ -0,0 +1,14 @@ +// Package folders are all the routines and sql for managing folders. +package folders + +import "unicode" + +// IsAlphaNum checks that a string just contains a-z0-9. +func IsAlphaNum(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} diff --git a/repl/command.go b/repl/command.go index f597551573cb8caa247c6f9c24ab3999bd835bf0..11913d98e5e115c859d880e661a41e87cffa280e 100644 --- a/repl/command.go +++ b/repl/command.go @@ -1214,6 +1214,7 @@ After selecting a folder, the user will notified of the number of unread messages, and the message pointer will be placed at the first unread message.`, Action: ActionSelect, + MinArgs: 1, MaxArgs: 1, Flags: dclish.Flags{ "/MARKED": { diff --git a/repl/folders.go b/repl/folders.go index 2d9351f2bb65560b167fb1a9574becb64c1a1ee2..900f8b74dbd806b335238370b975aed3187305ed 100644 --- a/repl/folders.go +++ b/repl/folders.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "git.lyda.ie/kevin/bulletin/accounts" "git.lyda.ie/kevin/bulletin/dclish" @@ -28,6 +29,7 @@ func ActionIndex(_ *dclish.Command) error { // ActionCreate handles the `CREATE` command. This creates a folder. func ActionCreate(cmd *dclish.Command) error { + // Populate options... options := folders.FolderCreateOptions{} if cmd.Flags["/ALWAYS"].Value == "true" { options.Always = 1 @@ -35,11 +37,9 @@ func ActionCreate(cmd *dclish.Command) error { if cmd.Flags["/BRIEF"].Value == "true" { options.Brief = 1 } - if cmd.Flags["/DESCRIPTION"].Value == "" { - // TODO: prompt the user for a description. - return errors.New("Description is required - use /DESCRIPTION") + if cmd.Flags["/DESCRIPTION"].Value != "" { + options.Description = cmd.Flags["/DESCRIPTION"].Value } - options.Description = cmd.Flags["/DESCRIPTION"].Value if cmd.Flags["/NOTIFY"].Value == "true" { options.Notify = 1 } @@ -75,6 +75,17 @@ func ActionCreate(cmd *dclish.Command) error { options.Visibility = folders.FolderSemiPrivate } + // Verify options... + if options.Description == "" { + var err error + options.Description, err = accounts.GetLine("Enter one line description of folder: ") + if err != nil { + return nil + } + } + if options.Description == "" || len(options.Description) > 53 { + return errors.New("Description must exist and be under 53 characters") + } err := accounts.User.Folders.CreateFolder(cmd.Args[0], options) // TODO: handle the /ID flag. return err @@ -82,10 +93,22 @@ func ActionCreate(cmd *dclish.Command) error { // ActionSelect handles the `SELECT` command. This selects a folder. func ActionSelect(cmd *dclish.Command) error { - if accounts.User.Folders.IsFolderAccess(cmd.Args[0], accounts.User.Login) { - accounts.User.CurrentFolder = cmd.Args[0] - } - return nil + if strings.Contains(cmd.Args[0], "%") { + return errors.New("Folder name cannot contain a %") + } + folder := accounts.User.Folders.FindFolder(cmd.Args[0]) + if folder == "" { + return errors.New("Unable to select the folder") + } + if accounts.User.Folders.IsFolderAccess(folder, accounts.User.Login) { + accounts.User.CurrentFolder = folder + fmt.Printf("Folder has been set to '%s'.\n", folder) + return nil + } + // TODO: Should be: + // WRITE(6,'('' You are not allowed to access folder.'')') + // WRITE(6,'('' See '',A,'' if you wish to access folder.'')') + return errors.New("Unable to select the folder") } // ActionModify handles the `MODIFY` command. This modifies a folder. diff --git a/repl/messages.go b/repl/messages.go index 507b091028bae05269690a4502b627ba74d0e751..ae292944a4f57175362566136746454e4d3c3128 100644 --- a/repl/messages.go +++ b/repl/messages.go @@ -18,11 +18,36 @@ import ( func ActionDirectory(cmd *dclish.Command) error { // TODO: flag parsing. if len(cmd.Args) == 1 { - folder := strings.ToUpper(cmd.Args[0]) - // TODO: Check folder is valid. + if strings.Contains(cmd.Args[0], "%") { + return errors.New("Folder name cannot contain a %") + } + folder := accounts.User.Folders.FindFolder(cmd.Args[0]) + if folder == "" { + return errors.New("Unable to select the folder") + } + if !accounts.User.Folders.IsFolderAccess(folder, accounts.User.Login) { + // TODO: Should be: + // WRITE(6,'('' You are not allowed to access folder.'')') + // WRITE(6,'('' See '',A,'' if you wish to access folder.'')') + return errors.New("Unable to select the folder") + } accounts.User.CurrentFolder = folder } - fmt.Println("TODO: List messages in folder") + msgs, err := accounts.User.Folders.ListMessages( + accounts.User.CurrentFolder, nil) + if err != nil { + return err + } + if len(msgs) == 0 { + fmt.Println("There are no messages present.") + return nil + } + fmt.Printf("%4s %-43s %-12s %-10s\n", "#", "Subject", "From", "Date") + for _, msg := range msgs { + fmt.Printf("%4d %-43s %-12s %-10s\n", msg.ID, msg.Subject, msg.Author, + msg.CreateAt.Format("2006-05-04 15:02:01")) + } + return nil } @@ -158,7 +183,22 @@ func ActionNext(cmd *dclish.Command) error { // ActionRead handles the `READ` command. func ActionRead(cmd *dclish.Command) error { - fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description) + // TODO: We need to set accounts.User.CurrentMessage when we change folder. + msgid := accounts.User.CurrentMessage + if len(cmd.Args) == 1 { + var err error + msgid, err = strconv.Atoi(cmd.Args[0]) + if err != nil { + return err + } + } + msg, err := accounts.User.Folders.ReadMessage( + accounts.User.Login, accounts.User.CurrentFolder, msgid) + if err != nil { + return err + } + // TODO: update accounts.User.CurrentMessage + fmt.Printf("%s\n", msg) return nil } diff --git a/repl/repl.go b/repl/repl.go index 88e97a4e41e30ab6321da51f00f94da5ababae01..07afdf823180cb9de6a1f48f59ccc8b17a996ddb 100644 --- a/repl/repl.go +++ b/repl/repl.go @@ -4,6 +4,7 @@ package repl import ( "fmt" "path" + "unicode" "git.lyda.ie/kevin/bulletin/accounts" "github.com/adrg/xdg" @@ -54,6 +55,19 @@ func Loop() error { if len(line) == 0 { continue } + prependRead := false + for _, r := range line { + if unicode.IsDigit(r) { + prependRead = true + break + } + if !unicode.IsSpace(r) { + break + } + } + if prependRead { + line = "READ " + line + } err = commands.ParseAndRun(line) if err != nil { fmt.Printf("ERROR: %s.\n", err)