diff --git a/server/apiv1/api.go b/server/apiv1/api.go index ed1128f..01f2be3 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -62,10 +62,11 @@ func GetMessages(w http.ResponseWriter, r *http.Request) { res.Start = start res.Messages = messages - res.Count = len(messages) + res.Count = len(messages) // legacy - now undocumented in API specs res.Total = stats.Total res.Unread = stats.Unread res.Tags = stats.Tags + res.MessagesCount = stats.Total bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") @@ -109,7 +110,7 @@ func Search(w http.ResponseWriter, r *http.Request) { start, limit := getStartLimit(r) - messages, err := storage.Search(search, start, limit) + messages, results, err := storage.Search(search, start, limit) if err != nil { httpError(w, err.Error()) return @@ -121,8 +122,9 @@ func Search(w http.ResponseWriter, r *http.Request) { res.Start = start res.Messages = messages - res.Count = len(messages) + res.Count = results // legacy - now undocumented in API specs res.Total = stats.Total + res.MessagesCount = results res.Unread = stats.Unread res.Tags = stats.Tags diff --git a/server/apiv1/structs.go b/server/apiv1/structs.go index ec6f01e..cfe78a5 100644 --- a/server/apiv1/structs.go +++ b/server/apiv1/structs.go @@ -12,9 +12,17 @@ type MessagesSummary struct { // Total number of unread messages in mailbox Unread int `json:"unread"` - // Number of results returned + // Legacy - now undocumented in API specs but left for backwards compatibility. + // Removed from API documentation 2023-07-12 + // swagger:ignore Count int `json:"count"` + // Total number of messages matching current query + MessagesCount int `json:"messages_count"` + + // // Number of results returned on current page + // Count int `json:"count"` + // Pagination offset Start int `json:"start"` diff --git a/server/server_test.go b/server/server_test.go index a489efd..fc41dd1 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -195,7 +195,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) { return } - assertEqual(t, count, m.Count, "wrong search results count") + assertEqual(t, count, m.MessagesCount, "wrong search results count") } func insertEmailData(t *testing.T) { diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 93afc71..89e35ac 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -26,6 +26,7 @@ export default { limit: 50, total: 0, unread: 0, + messagesCount: 0, start: 0, count: 0, tags: [], @@ -75,7 +76,7 @@ export default { return this.start > 0 }, canNext: function () { - return this.total > (this.start + this.count) + return this.messagesCount > (this.start + this.count) }, unreadInSearch: function () { if (!this.searching) { @@ -146,11 +147,12 @@ export default { let uri = 'api/v1/messages' if (self.search) { self.searching = true - self.items = [] uri = 'api/v1/search' - self.start = 0 // search is displayed on one page params['query'] = self.search - params['limit'] = 200 + params['limit'] = self.limit + if (self.start > 0) { + params['start'] = self.start + } } else { self.searching = false params['limit'] = self.limit @@ -162,7 +164,8 @@ export default { self.get(uri, params, function (response) { self.total = response.data.total self.unread = response.data.unread - self.count = response.data.count + self.count = response.data.messages.length + self.messagesCount = response.data.messages_count self.start = response.data.start self.items = response.data.messages self.tags = response.data.tags @@ -194,6 +197,7 @@ export default { doSearch: function (e) { e.preventDefault() + this.start = 0 this.loadMessages() }, @@ -204,13 +208,14 @@ export default { } this.search = 'tag:' + tag window.location.hash = "" + this.start = 0 this.loadMessages() }, resetSearch: function (e) { e.preventDefault() this.search = '' - this.scrollInPlace = true + this.start = 0 this.loadMessages() }, @@ -459,9 +464,9 @@ export default { // websocket connect connect: function () { - let wsproto = location.protocol == 'https:' ? 'wss' : 'ws' + let proto = location.protocol == 'https:' ? 'wss' : 'ws' let ws = new WebSocket( - wsproto + "://" + document.location.host + document.location.pathname + "api/events" + proto + "://" + document.location.host + document.location.pathname + "api/events" ) let self = this ws.onmessage = function (e) { @@ -809,7 +814,7 @@ export default { <i class="bi bi-check2-square"></i> </button> - <select v-model="limit" v-on:change="loadMessages" v-if="!searching" + <select v-model="limit" v-on:change="loadMessages" class="form-select form-select-sm d-none d-md-inline w-auto me-2"> <option value="25">25</option> <option value="50">50</option> @@ -817,23 +822,18 @@ export default { <option value="200">200</option> </select> - <span v-if="searching"> - <b>{{ formatNumber(items.length) }} result<template v-if="items.length != 1">s</template></b> - </span> - <span v-else> - <small> - {{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> - {{ formatNumber(total) }} - </small> - <button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" v-if="!searching" - :title="'View previous ' + limit + ' messages'"> - <i class="bi bi-caret-left-fill"></i> - </button> - <button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" v-if="!searching" - :title="'View next ' + limit + ' messages'"> - <i class="bi bi-caret-right-fill"></i> - </button> - </span> + <small> + {{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> + {{ formatNumber(messagesCount) }} + </small> + <button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" + :title="'View previous ' + limit + ' messages'"> + <i class="bi bi-caret-left-fill"></i> + </button> + <button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" + :title="'View next ' + limit + ' messages'"> + <i class="bi bi-caret-right-fill"></i> + </button> </div> </div> <div class="row flex-fill" style="min-height:0"> diff --git a/server/ui-src/templates/Message.vue b/server/ui-src/templates/Message.vue index be3a77a..467b637 100644 --- a/server/ui-src/templates/Message.vue +++ b/server/ui-src/templates/Message.vue @@ -23,11 +23,12 @@ export default { return { srcURI: false, iframes: [], // for resizing - showTags: false, // to force rerendering of component + showTags: false, // to force re-rendering of component + canSaveTags: false, // prevent auto-saving tags on render messageTags: [], allTags: [], loadHeaders: false, - showMobileBtns: false, + showMobileButtons: false, scaleHTMLPreview: 'display', // keys names match bootstrap icon names responsiveSizes: { @@ -39,20 +40,25 @@ export default { }, watch: { + // handle changes to the URL messageID message: { handler() { let self = this self.showTags = false + self.canSaveTags = false self.messageTags = self.message.Tags self.allTags = self.existingTags self.loadHeaders = false - self.scaleHTMLPreview = 'display';// default view + self.scaleHTMLPreview = 'display' // default view // delay to select first tab and add HTML highlighting (prev/next) self.$nextTick(function () { self.renderUI() self.showTags = true self.$nextTick(function () { Tags.init("select[multiple]") + window.setTimeout(function () { + self.canSaveTags = true + }, 200) }) }) }, @@ -60,8 +66,8 @@ export default { immediate: true }, messageTags() { - // save changed to tags - if (this.showTags) { + // save changes to tags + if (this.canSaveTags) { this.saveTags() } }, @@ -78,6 +84,7 @@ export default { mounted() { let self = this self.showTags = false + self.canSaveTags = false self.allTags = self.existingTags window.addEventListener("resize", self.resizeIframes) self.renderUI() @@ -95,7 +102,12 @@ export default { self.showTags = true self.$nextTick(function () { - Tags.init("select[multiple]") + self.$nextTick(function () { + Tags.init('select[multiple]') + window.setTimeout(function () { + self.canSaveTags = true + }, 200) + }) }) }, @@ -104,6 +116,10 @@ export default { }, methods: { + isHTMLTabSelected: function () { + this.showMobileButtons = this.$refs.navhtml + && this.$refs.navhtml.classList.contains('active') + }, renderUI: function () { let self = this // click the first non-disabled tab @@ -111,6 +127,14 @@ export default { document.activeElement.blur() // blur focus document.getElementById('message-view').scrollTop = 0 + self.isHTMLTabSelected() + + document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) { + listObj.addEventListener('shown.bs.tab', function (event) { + self.isHTMLTabSelected() + }) + }) + // delay 0.2s until vue has rendered the iframe content window.setTimeout(function () { let p = document.getElementById('preview-html') @@ -311,29 +335,30 @@ export default { <nav> <div class="nav nav-tabs my-3" id="nav-tab" role="tablist"> <button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button" - role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" - v-on:click="showMobileBtns = true; resizeIframes()">HTML</button> + role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" ref="navhtml" + v-on:click="resizeIframes()">HTML</button> <button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source" - type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML" - v-on:click=" showMobileBtns = false"> + type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML"> HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span> </button> <button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text" type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false" - :class="message.HTML == '' ? 'show' : ''" v-on:click=" showMobileBtns = false">Text</button> + :class="message.HTML == '' ? 'show' : ''"> + Text + </button> <button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers" - type="button" role="tab" aria-controls="nav-headers" aria-selected="false" - v-on:click=" showMobileBtns = false"> + type="button" role="tab" aria-controls="nav-headers" aria-selected="false"> <span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span> </button> <button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button" - role="tab" aria-controls="nav-raw" aria-selected="false" - v-on:click=" showMobileBtns = false">Raw</button> + role="tab" aria-controls="nav-raw" aria-selected="false"> + Raw + </button> - <div class="d-none d-lg-block ms-auto me-2" v-if="showMobileBtns"> - <template v-for=" vals, key in responsiveSizes "> + <div class="d-none d-lg-block ms-auto me-2" v-if="showMobileButtons"> + <template v-for="vals, key in responsiveSizes"> <button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'" - v-on:click=" scaleHTMLPreview = key"> + v-on:click="scaleHTMLPreview = key"> <i class="bi" :class="'bi-' + key"></i> </button> </template> diff --git a/storage/database.go b/storage/database.go index 192f149..5fd6e20 100644 --- a/storage/database.go +++ b/storage/database.go @@ -384,9 +384,14 @@ func List(start, limit int) ([]MessageSummary, error) { // The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term> // Negative searches also also included by prefixing the search term with a `-` or `!` -func Search(search string, start, limit int) ([]MessageSummary, error) { +func Search(search string, start, limit int) ([]MessageSummary, int, error) { results := []MessageSummary{} + allResults := []MessageSummary{} tsStart := time.Now() + nrResults := 0 + if limit < 0 { + limit = 50 + } s := strings.ToLower(search) // add another quote if missing closing quote @@ -398,11 +403,11 @@ func Search(search string, start, limit int) ([]MessageSummary, error) { p := shellwords.NewParser() args, err := p.Parse(s) if err != nil { - return results, errors.New("Your search contains invalid characters") + return results, nrResults, errors.New("Your search contains invalid characters") } // generate the SQL based on arguments - q := searchParser(args, start, limit) + q := searchParser(args) if err := q.QueryAndClose(nil, db, func(row *sql.Rows) { var created int64 @@ -440,18 +445,29 @@ func Search(search string, start, limit int) ([]MessageSummary, error) { em.Attachments = attachments em.Read = read == 1 - results = append(results, em) + allResults = append(allResults, em) }); err != nil { - return results, err + return results, nrResults, err + } + + dbLastAction = time.Now() + + nrResults = len(allResults) + + if nrResults > start { + end := nrResults + if nrResults >= start+limit { + end = start + limit + } + + results = allResults[start:end] } elapsed := time.Since(tsStart) logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed) - dbLastAction = time.Now() - - return results, err + return results, nrResults, err } // GetMessage returns a Message generated from the mailbox_data collection. diff --git a/storage/database_test.go b/storage/database_test.go index 344bdc1..e9876ee 100644 --- a/storage/database_test.go +++ b/storage/database_test.go @@ -168,9 +168,9 @@ func TestSearch(t *testing.T) { for i := 1; i < 51; i++ { // search a random something that will return a single result - searchIndx := rand.Intn(4) + 1 + searchIdx := rand.Intn(4) + 1 var search string - switch searchIndx { + switch searchIdx { case 1: search = fmt.Sprintf("from-%d@example.com", i) case 2: @@ -181,7 +181,7 @@ func TestSearch(t *testing.T) { search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i) } - summaries, err := Search(search, 0, 100) + summaries, _, err := Search(search, 0, 100) if err != nil { t.Log("error ", err) t.Fail() @@ -196,8 +196,8 @@ func TestSearch(t *testing.T) { assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match") } - // search something that will return 200 rsults - summaries, err := Search("This is the email body", 0, testRuns) + // search something that will return 200 results + summaries, _, err := Search("This is the email body", 0, testRuns) if err != nil { t.Log("error ", err) t.Fail() diff --git a/storage/search.go b/storage/search.go index a3824cd..519bd78 100644 --- a/storage/search.go +++ b/storage/search.go @@ -8,25 +8,14 @@ import ( ) // SearchParser returns the SQL syntax for the database search based on the search arguments -func searchParser(args []string, start, limit int) *sqlf.Stmt { - if limit == 0 { - limit = 50 - } - +func searchParser(args []string) *sqlf.Stmt { q := sqlf.From("mailbox"). Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON, IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON, IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON, IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON - `). - OrderBy("Created DESC"). - Limit(limit). - Offset(start) - - if limit > 0 { - q = q.Limit(limit) - } + `).OrderBy("Created DESC") for _, w := range args { if cleanString(w) == "" {