From 497e5a787338f5e8561be4b0f5baf8c771d975bf Mon Sep 17 00:00:00 2001 From: zzz Date: Sat, 17 Feb 2024 10:32:18 -0500 Subject: [PATCH] Draft: WIP: Add search box to susimail Searches subject and recipients only. Not a full-text search. No index is generated or persisted. Search is per-folder only, from the folder (LIST) view state only. Supports iteration and paging within results. This is a POST search only. No js/XHR support. Splits up the single HTML form in folder view to 5 forms, as necessary to do this sanely, and also as prep for js search later. WIP, may contain bugs and some small unrelated changes, not for 2.5.0. TODO: Testing, cleanup, verify all state transitions handled, more planning for js, and then js. --- apps/susimail/src/js/folder.js | 6 +- .../src/src/i2p/susi/util/Folder.java | 148 +++++++++-- .../src/src/i2p/susi/webmail/WebMail.java | 245 ++++++++++++++---- apps/susimail/src/themes/dark/susimail.css | 24 ++ apps/susimail/src/themes/light/susimail.css | 26 +- 5 files changed, 382 insertions(+), 67 deletions(-) diff --git a/apps/susimail/src/js/folder.js b/apps/susimail/src/js/folder.js index 8f036599a..0288a4602 100644 --- a/apps/susimail/src/js/folder.js +++ b/apps/susimail/src/js/folder.js @@ -40,7 +40,7 @@ function addClickHandler1(elem) function addClickHandler2(elem) { elem.addEventListener("click", function() { - var form = document.forms[0]; + var form = document.forms[3]; form.delete.disabled = false; form.markall.disabled = true; form.clearselection.disabled = false; @@ -57,7 +57,7 @@ function addClickHandler2(elem) function addClickHandler3(elem) { elem.addEventListener("click", function() { - var form = document.forms[0]; + var form = document.forms[3]; form.delete.disabled = true; form.markall.disabled = false; form.clearselection.disabled = true; @@ -82,7 +82,7 @@ function deleteboxclicked() { var hasOne = false; var hasAll = true; var hasNone = true; - var form = document.forms[0]; + var form = document.forms[3]; for(i = 0; i < form.elements.length; i++) { var elem = form.elements[i]; if (elem.type == 'checkbox') { diff --git a/apps/susimail/src/src/i2p/susi/util/Folder.java b/apps/susimail/src/src/i2p/susi/util/Folder.java index e874b75bb..5c86a353e 100644 --- a/apps/susimail/src/src/i2p/susi/util/Folder.java +++ b/apps/susimail/src/src/i2p/susi/util/Folder.java @@ -61,12 +61,28 @@ public class Folder { UP; } + /** + * @since 0.9.63 + */ + public interface Selector { + /** + * @return non-null + */ + public String getSelectionKey(); + /** + * @return true if selected + */ + public boolean select(O element); + } + private int pages, pageSize, currentPage; private O[] elements; private final Map> sorter; private SortOrder sortingDirection; private Comparator currentSorter; private String currentSortID; + private Selector currentSelector; + private List selected; public Folder() { @@ -109,10 +125,18 @@ public class Folder { /** * Returns the number of pages in the folder. * Minimum of 1 even if empty. + * If the selector is non-null, this will be the number of pages in the selected results. + * * @return Returns the number of pages. */ public synchronized int getPages() { - return pages; + if (currentSelector == null || elements == null) + return pages; + int ps = getPageSize(); + int rv = selected.size() / ps; + if (rv * ps < elements.length) + rv++; + return rv; } /** @@ -177,6 +201,7 @@ public class Folder { if (elements.length > 0) { this.elements = elements; sort(); + select(); } else { this.elements = null; } @@ -285,6 +310,27 @@ public class Folder { return list.iterator(); } + /** + * Returns an iterator containing the elements on the current page. + * This iterator is over a copy of the current page, and so + * is thread safe w.r.t. other operations on this folder, + * but will not reflect subsequent changes, and iter.remove() + * will not change the folder. + * + * @return Iterator containing the elements on the current page. + * @since 0.9.63 + */ + public synchronized Iterator currentPageSelectorIterator() + { + if (currentSelector == null) + return currentPageIterator(); + int pageSize = getPageSize(); + int offset = ( currentPage - 1 ) * pageSize; + if (selected == null || offset > selected.size()) + return Collections.emptyList().iterator(); + return selected.subList(offset, Math.min(selected.size(), offset + pageSize)).iterator(); + } + /** * Turns folder to next page. */ @@ -370,27 +416,54 @@ public class Folder { public synchronized SortOrder getCurrentSortingDirection() { return sortingDirection; } - + /** - * Returns the element on the current page on the given position. + * Warning, this does not do the actual selection, this is done in the iterator. + * Resets page to 1 if selector changed. * - * @param x Position of the element on the current page. - * @return Element on the current page on the given position. + * @param selector may be null + * @since 0.9.63 */ -/**** unused, we now fetch by UIDL, not position - public synchronized O getElementAtPosXonCurrentPage( int x ) - { - O result = null; - if( elements != null ) { - int pageSize = getPageSize(); - int offset = ( currentPage - 1 ) * pageSize; - offset += x; - if( offset >= 0 && offset < elements.length ) - result = elements[offset]; + public synchronized void setSelector(Selector selector) { + if ((currentSelector != null && selector == null) || + (currentSelector == null && selector != null) || + (currentSelector != null && selector != null && + !currentSelector.getSelectionKey().equals(selector.getSelectionKey()))) { + currentPage = 1; + } + currentSelector = selector; + if (selector != null) + select(); + else + selected = null; + } + + /** + * @return current selector or null + * @since 0.9.63 + */ + public synchronized Selector getCurrentSelector() { + return currentSelector; + } + + /** + * Select and cache results + * @since 0.9.63 + */ + private synchronized void select() { + if (selected == null) + selected = new ArrayList(); + else + selected.clear(); + if (elements == null || currentSelector == null) + return; + int sz = getSize(); + for (int i = 0; i < sz; i++) { + if (currentSelector.select(elements[i])) { + selected.add(elements[i]); + } } - return result; } -****/ /** * Returns the first element of the sorted folder. @@ -467,6 +540,47 @@ public class Folder { } return result; } + + /** + * Retrieves the next element in the sorted array. + * + * @param element + * @return The next element + */ + public synchronized O getNextSelectedElement(O element) + { + if (currentSelector == null) + return getNextElement(element); + O result = null; + int i = selected.indexOf(element); + if (i != -1 && selected != null) { + i++; + if (i < selected.size()) + result = selected.get(i); + } + return result; + } + + /** + * Retrieves the previous element in the sorted array. + * + * @param element + * @return The previous element + */ + public synchronized O getPreviousSelectedElement(O element) + { + if (currentSelector == null) + return getPreviousElement(element); + O result = null; + int i = selected.indexOf(element); + if (i != -1 && selected != null) { + i--; + if (i >= 0 && i < selected.size()) + result = selected.get(i); + } + return result; + } + /** * Retrieves element at index i. * diff --git a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java index 8d498f8d0..ee57e9bc7 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java +++ b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java @@ -145,6 +145,7 @@ public class WebMail extends HttpServlet private static final String NEXT_PAGE_NUM = "nextpagenum"; private static final String CURRENT_SORT = "currentsort"; private static final String CURRENT_FOLDER = "folder"; + private static final String CURRENT_SEARCH = "currentsearch"; private static final String NEW_FOLDER = "newfolder"; private static final String DRAFT_EXISTS = "draftexists"; private static final String DEBUG_STATE = "currentstate"; @@ -167,6 +168,7 @@ public class WebMail extends HttpServlet private static final String REALLYDELETE = "really_delete"; private static final String MOVE_TO = "moveto"; private static final String SWITCH_TO = "switchto"; + private static final String SEARCH = "s"; // also a GET param private static final String SHOW = "show"; private static final String DOWNLOAD = "download"; @@ -412,6 +414,8 @@ public class WebMail extends HttpServlet buf.append(" beforePopup\""); else buf.append('"'); + if (name.equals(NEW_UPLOAD)) + buf.append(" id=\"" + NEW_UPLOAD + '"'); // These are icons only now, via the CSS, so add a tooltip if (name.equals(FIRSTPAGE) || name.equals(PREVPAGE) || name.equals(NEXTPAGE) || name.equals(LASTPAGE) || name.equals(PREV) || name.equals(LIST) || name.equals(NEXT)) @@ -635,7 +639,7 @@ public class WebMail extends HttpServlet "\" id=\"mailhtmlframe" + mailPart.getID() + "\" class=\"iframedsusi\" width=\"100%\" height=\"100%\" scrolling=\"auto\" frameborder=\"0\" border=\"0\" allowtransparency=\"true\">"); out.println("" ); - out.println("

" + _t("To protect your privacy, SusiMail has blocked remote content in this message.") + ""); + out.println("

" + _t("To protect your privacy, SusiMail has blocked remote content in this message.") + ""); out.println("

"); // TODO scrolling=no if js is on } else if (showBody) { @@ -1177,7 +1181,8 @@ public class WebMail extends HttpServlet buttonPressed( request, CLEAR ) || buttonPressed( request, INVERT ) || buttonPressed( request, SORT ) || - buttonPressed( request, REFRESH )) { + buttonPressed( request, REFRESH ) || + buttonPressed( request, SEARCH )) { state = State.LIST; } else if (buttonPressed(request, PREV) || buttonPressed(request, NEXT) || @@ -1205,6 +1210,9 @@ public class WebMail extends HttpServlet } else if (buttonPressed(request, DRAFT_ATTACHMENT)) { // GET params state = State.NEW; + } else if (buttonPressed(request, SEARCH)) { + // GET params for XHR + return State.LIST; } /* @@ -1809,7 +1817,7 @@ public class WebMail extends HttpServlet /** * Recursive. - * @param id a content-id, without the surrounding <> or trailing @ part + * @param id a content-id, without the surrounding <> * @return the part or null * @since 0.9.62 */ @@ -1818,14 +1826,6 @@ public class WebMail extends HttpServlet return null; if (id.equals(part.cid)) return part; - if (part.cid != null) { - // strip @ and try again, - int idx = part.cid.indexOf('@'); - if (idx > 0) { - if (id.equals(part.cid.substring(0, idx))) - return part; - } - } if( part.multipart || part.message ) { for( MailPart p : part.parts ) { MailPart subPart = getMailPartFromID(p, id); @@ -2191,8 +2191,9 @@ public class WebMail extends HttpServlet boolean isMobile = (forceMobileConsole || isMobile(httpRequest.getHeader("User-Agent"))); httpRequest.setCharacterEncoding("UTF-8"); - response.setHeader("X-Frame-Options", "SAMEORIGIN"); + response.setHeader("X-Frame-Options", "SAMEORIGIN"); // very strict CSP for HTML emails in iframes + // TODO self paths, review if (httpRequest.getParameter(RAW_ATTACHMENT) != null || httpRequest.getParameter(CID_ATTACHMENT) != null || httpRequest.getParameter(DRAFT_ATTACHMENT) != null) { @@ -2211,7 +2212,7 @@ public class WebMail extends HttpServlet response.setCharacterEncoding("UTF-8"); } response.setHeader("Permissions-Policy", "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture(), fullscreen=(self), geolocation=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=(), vibrate=(), vr=()"); - response.setHeader("X-XSS-Protection", "1; mode=block"); + response.setHeader("X-XSS-Protection", "1; mode=block"); response.setHeader("X-Content-Type-Options", "nosniff"); response.setHeader("Referrer-Policy", "no-referrer"); response.setHeader("Accept-Ranges", "none"); @@ -2568,15 +2569,31 @@ public class WebMail extends HttpServlet } out.println(""); out.print("\n"); - String nonce = state == State.AUTH ? LOGIN_NONCE : - Long.toString(ctx.random().nextLong()); - sessionObject.addNonce(nonce); - out.println( - "
" + - "
\n" + - "\n" + - // we use this to know if the user thought he was logged in at the time - ""); + out.println("
"); + + if (state != State.LIST) { + // For all states except LIST, we have one big form for the whole page. + // LIST has several forms, we will output them in showFolder(). + String nonce = state == State.AUTH ? LOGIN_NONCE : + Long.toString(ctx.random().nextLong()); + sessionObject.addNonce(nonce); + out.println("\n" + + "\n" + + // we use this to know if the user thought he was logged in at the time + ""); + if (state != State.AUTH && state != State.CONFIG && state != State.LOADING) { + // maintain the search param when changing pages or folders or + // going to message view and back + String search = request.getParameter(CURRENT_SEARCH); + if (search == null || search.length() == 0) { + Folder.Selector selector = mc.getFolder().getCurrentSelector(); + if (selector != null) + search = selector.getSelectionKey(); + } + if (search != null && search.length() > 0) + out.println("\n"); + } + } if (state == State.NEW) { String newUIDL = request.getParameter(NEW_UIDL); if (newUIDL == null || newUIDL.length() <= 0) @@ -2600,8 +2617,6 @@ public class WebMail extends HttpServlet } out.println(""); } - } - if (state == State.SHOW || state == State.NEW || state == State.LIST) { // Save sort order in case it changes later String curSort = folder.getCurrentSortBy(); SortOrder curOrder = folder.getCurrentSortingDirection(); @@ -2652,6 +2667,7 @@ public class WebMail extends HttpServlet } out.println("
" ); } + /* * now write body */ @@ -2689,7 +2705,8 @@ public class WebMail extends HttpServlet else if( state == State.CONFIG ) showConfig(out, folder); - out.println("
\n"); + if (state != State.LIST) + out.println("\n"); out.println("

\n" + "\"susimail\"\n" + @@ -3426,18 +3443,9 @@ public class WebMail extends HttpServlet */ private static void showFolder( PrintWriter out, SessionObject sessionObject, MailCache mc, RequestWrapper request ) { - out.println("

"); - out.println( button( NEW, _t("New") ) + spacer); - // In theory, these are valid and will apply to the first checked message, - // but that's not obvious and did it work? - //button( REPLY, _t("Reply") ) + - //button( REPLYALL, _t("Reply All") ) + - //button( FORWARD, _t("Forward") ) + spacer + - //button( DELETE, _t("Delete") ) + spacer + String folderName = mc.getFolderName(); String floc; if (folderName.equals(DIR_FOLDER)) { - out.println((sessionObject.isFetching ? button2(REFRESH, _t("Check Mail")) : button(REFRESH, _t("Check Mail"))) + spacer); floc = ""; } else if (folderName.equals(DIR_DRAFTS)) { floc = ""; @@ -3446,11 +3454,75 @@ public class WebMail extends HttpServlet } boolean isSpamFolder = folderName.equals(DIR_SPAM); boolean showToColumn = folderName.equals(DIR_DRAFTS) || folderName.equals(DIR_SENT); - //if (Config.hasConfigFile()) - // out.println(button( RELOAD, _t("Reload Config") ) + spacer); - out.println(button( LOGOUT, _t("Logout") )); - int page = 1; + // For all states except LIST, we have one big form for the whole page. + // Here, for LIST, we set up 4-5 forms + // to deal with html rules and have a search box that works right. + // 1: new/checkmail/logout, inside topbuttons div + // 2: search, inside topbuttons div + // 3: change folder and page buttons, inside topbuttons div, surrounds pagenav table + // 4: mail delete boxes and bottombuttons div, surrounds mailbox table + // 5: change folder and page buttons, inside 2nd topbuttons div, surrounds pagenav table, + // only shown if more than 30 mails on a page + // + // Create the common form header + I2PAppContext ctx = I2PAppContext.getGlobalContext(); + String nonce = Long.toString(ctx.random().nextLong()); + sessionObject.addNonce(nonce); + // for all but search + String form = "
\n"; + StringBuilder fbf = new StringBuilder(256); + fbf.append("\n") + .append("\n"); Folder folder = mc.getFolder(); + String curSort = folder.getCurrentSortBy(); + SortOrder curOrder = folder.getCurrentSortingDirection(); + // UP is reverse sort. DOWN is normal sort. + String fullSort = curOrder == SortOrder.UP ? '-' + curSort : curSort; + fbf.append("\n") + .append("\n"); + String cursearch = request.getParameter(CURRENT_SEARCH); + String search = request.getParameter(SEARCH); + if (cursearch != null && cursearch.length() > 0) { + if (search == null) { + // we came from somewhere else, set search to cursearch, will set selector below + search = cursearch; + } else { + // we came from here, search wins, will set selector below + } + } + if (search != null && search.length() > 0) { + fbf.append("\n"); + Folder.Selector olds = folder.getCurrentSelector(); + if (olds == null || !olds.getSelectionKey().equals(search)) { + folder.setSelector(new SearchSelector(mc, search)); + } + } else if ((search == null || search.length() == 0) && folder.getCurrentSelector() != null) { + folder.setSelector(null); + } + String hidden = fbf.toString(); + + out.println("
"); + // form 1 + out.print(form); + out.print(hidden); + out.println( button( NEW, _t("New") ) + spacer); + if (folderName.equals(DIR_FOLDER)) + out.println((sessionObject.isFetching ? button2(REFRESH, _t("Check Mail")) : button(REFRESH, _t("Check Mail"))) + spacer); + out.println(button( LOGOUT, _t("Logout") )); + out.println(""); + + if (folder.getSize() > 1 || (search != null && search.length() > 0)) { + // form 2 + out.println("
"); + out.print(hidden); + out.write(""); + out.println("
"); + } + + int page = 1; if (folder.getPages() > 1) { String sp = request.getParameter(CUR_PAGE); if (sp != null) { @@ -3460,13 +3532,18 @@ public class WebMail extends HttpServlet } folder.setCurrentPage(page); } + // form 3 + out.print(form); + out.print(hidden); showPageButtons(out, sessionObject.user, folderName, page, folder.getPages(), true); + out.println(""); out.println("
"); - String curSort = folder.getCurrentSortBy(); - SortOrder curOrder = folder.getCurrentSortingDirection(); - out.println("\n" + - "\n" + + // form 4 + out.print(form); + out.print(hidden); + out.println("

 
\n"); + out.println("\n" + thSpacer + "" + thSpacer + "" + thSpacer + "" ); int bg = 0; int i = 0; - for (Iterator it = folder.currentPageIterator(); it != null && it.hasNext(); ) { + for (Iterator it = folder.currentPageSelectorIterator(); it != null && it.hasNext(); ) { String uidl = it.next(); Mail mail = mc.getMail(uidl, MailCache.FetchMode.HEADER_CACHE_ONLY); if (mail == null || !mail.hasHeader()) { @@ -3586,14 +3663,82 @@ public class WebMail extends HttpServlet out.print(button(CONFIGURE, _t("Settings"))); out.println(""); out.println( "

 " + sortHeader(SORT_SENDER, showToColumn ? _t("To") : _t("From"), sessionObject.imgPath, curSort, curOrder, page, folderName) + "" + sortHeader(SORT_SUBJECT, _t("Subject"), sessionObject.imgPath, curSort, curOrder, page, folderName) + "" + sortHeader(SORT_DATE, _t("Date"), sessionObject.imgPath, curSort, curOrder, page, folderName) + @@ -3474,7 +3551,7 @@ public class WebMail extends HttpServlet thSpacer + "" + sortHeader(SORT_SIZE, _t("Size"), sessionObject.imgPath, curSort, curOrder, page, folderName) + "
"); - if (folder.getPages() > 1 && i > 30) { + out.println(""); + int ps = folder.getPageSize(); + if (ps > 30 && (folder.getCurrentPage() - 1) * ps < folder.getSize() - 30) { // show the buttons again if page is big out.println("
"); + // form 5 + out.print(form); + out.print(hidden); showPageButtons(out, sessionObject.user, folderName, page, folder.getPages(), false); + out.println(""); out.println("
"); } } + /** + * Folder callback to search mails for matching terms. + * Only subject and sender (or recipients for drafts). + * Mail bodies are not in-memory and would be very slow. + * + * @since 0.9.63 + */ + private static class SearchSelector implements Folder.Selector { + private final String key; + private final MailCache mc; + private final String[] terms; + private final boolean isDrafts; + + /** + * @param search non-null, non-empty, and %-encoded, will be decoded here + */ + public SearchSelector(MailCache cache, String search) { + mc = cache; + isDrafts = mc.getFolderName().equals(DIR_DRAFTS); + key = search; + terms = DataHelper.split(search, " "); + // decode + for (int i = 0; i < terms.length; i++) { + terms[i] = terms[i].toLowerCase(Locale.US); + } + } + + public String getSelectionKey() { + return key; + } + + public boolean select(String uidl) { + Mail mail = mc.getMail(uidl, MailCache.FetchMode.HEADER_CACHE_ONLY); + if (mail == null) + return false; + String subj = mail.subject.toLowerCase(Locale.US); + String sender = isDrafts ? null : mail.sender; + if (sender != null) + sender = sender.toLowerCase(Locale.US); + String[] to = isDrafts ? mail.to : null; + for (String term : terms) { + if (subj.contains(term)) + return true; + if (sender != null && sender.contains(term)) + return true; + if (to != null) { + for (int i = 0; i < to.length; i++) { + if (to[i].toLowerCase(Locale.US).contains(term)) + return true; + } + } + } + return false; + } + + @Override + public String toString() { + return "Search selector for '" + key + "'"; + } + + } + /** * Folder selector, then, if pages greater than 1: * first prev next last @@ -3726,7 +3871,11 @@ public class WebMail extends HttpServlet out.println("
"); Folder folder = mc.getFolder(); if (hasHeader) { - String uidl = folder.getPreviousElement(showUIDL); + String uidl; + if (folder.getCurrentSelector() != null) + uidl = folder.getPreviousSelectedElement(showUIDL); + else + uidl = folder.getPreviousElement(showUIDL); String text = _t("Previous"); if (uidl == null || folder.isFirstElement(showUIDL)) { out.println(button2(PREV, text)); @@ -3741,7 +3890,11 @@ public class WebMail extends HttpServlet out.println(""); out.println(button( LIST, _t("Back to Folder") ) + spacer); if (hasHeader) { - String uidl = folder.getNextElement(showUIDL); + String uidl; + if (folder.getCurrentSelector() != null) + uidl = folder.getNextSelectedElement(showUIDL); + else + uidl = folder.getNextElement(showUIDL); String text = _t("Next"); if (uidl == null || folder.isLastElement(showUIDL)) { out.println(button2(NEXT, text)); diff --git a/apps/susimail/src/themes/dark/susimail.css b/apps/susimail/src/themes/dark/susimail.css index 35e73fc77..af1afcf79 100644 --- a/apps/susimail/src/themes/dark/susimail.css +++ b/apps/susimail/src/themes/dark/susimail.css @@ -244,6 +244,30 @@ div.topbuttons br { border-radius: 15px; } +#searchbox { + background: #f8f8ff url(/themes/console/images/buttons/search.png) 7px center no-repeat !important; + margin: 2px 4px 2px 24px !important; + padding: 4px 32px 4px 32px !important; + color: #47475f; +} + +#searchbox:focus, #searchbox:active { + color: #19191f; +} + +#searchcancel { + background: url(../images/delete.png) 0px center no-repeat; + margin: 9px 4px 2px 2px; + padding: 6px 12px; + color: transparent; + border: none; + float: left; +} + +#searchcancel.disabled { + display: none; +} + table#pagenav { float: right; width: 200px; diff --git a/apps/susimail/src/themes/light/susimail.css b/apps/susimail/src/themes/light/susimail.css index b1046626b..dae9e3fda 100644 --- a/apps/susimail/src/themes/light/susimail.css +++ b/apps/susimail/src/themes/light/susimail.css @@ -959,7 +959,7 @@ hr { } #composemail table { - width: auto; + width: 90%; margin: auto; } @@ -1187,6 +1187,30 @@ h3#config { float: none !important; } +#searchbox { + background: #f8f8ff url(/themes/console/images/buttons/search.png) 7px center no-repeat !important; + margin: 2px 4px 2px 24px !important; + padding: 4px 32px 4px 32px !important; + color: #47475f; +} + +#searchbox:focus, #searchbox:active { + color: #19191f; +} + +#searchcancel { + background: url(../images/delete.png) 0px center no-repeat; + margin: 9px 4px 2px 2px; + padding: 6px 12px; + color: transparent; + border: none; + float: left; +} + +#searchcancel.disabled { + display: none; +} + input.moveto { float: left; margin-left: 14px;