From 732c35625be8d7e7c2c2ea134a06c7a807460f4c Mon Sep 17 00:00:00 2001 From: antelle Date: Wed, 28 Apr 2021 17:34:48 +0200 Subject: [PATCH] entry selection filters --- app/scripts/comp/app/select-entry-filter.js | 55 +++++++---- app/scripts/locales/base.json | 5 +- app/scripts/util/formatting/url-format.js | 31 +++++++ app/scripts/views/select/select-entry-view.js | 92 +++++++++++++++---- app/styles/areas/_select-entry.scss | 41 ++++++--- app/styles/base/_icon-font.scss | 1 + app/styles/base/_theme-vars.scss | 4 + app/templates/select/select-entry.hbs | 19 ++-- release-notes.md | 1 + test/src/util/formatting/url-format.js | 30 ++++++ 10 files changed, 225 insertions(+), 54 deletions(-) diff --git a/app/scripts/comp/app/select-entry-filter.js b/app/scripts/comp/app/select-entry-filter.js index 9f20478d..0a888f33 100644 --- a/app/scripts/comp/app/select-entry-filter.js +++ b/app/scripts/comp/app/select-entry-filter.js @@ -6,7 +6,10 @@ const urlPartsRegex = /^(\w+:\/\/)?(?:(?:www|wwws|secure)\.)?([^\/]+)\/?(.*)/; class SelectEntryFilter { constructor(windowInfo, appModel, files, filterOptions) { this.title = windowInfo.title; + this.useTitle = !!windowInfo.title && !windowInfo.url; this.url = windowInfo.url; + this.useUrl = !!windowInfo.url; + this.subdomains = true; this.text = ''; this.appModel = appModel; this.files = files; @@ -22,7 +25,9 @@ class SelectEntryFilter { let entries = this.appModel .getEntriesByFilter(filter, this.files) .map((e) => [e, this._getEntryRank(e)]); - entries = entries.filter((e) => e[1]); + if (this.useUrl || this.useTitle) { + entries = entries.filter((e) => e[1]); + } entries = entries.sort((x, y) => x[1] === y[1] ? x[0].title.localeCompare(y[0].title) : y[1] - x[1] ); @@ -37,52 +42,68 @@ class SelectEntryFilter { } _getEntryRank(entry) { - let rank = 0; - if (this.titleLower && entry.title) { - rank += Ranking.getStringRank(entry.title.toLowerCase(), this.titleLower); + let titleRank = 0; + let urlRank = 0; + + if (this.useTitle && this.titleLower && entry.title) { + titleRank = Ranking.getStringRank(entry.title.toLowerCase(), this.titleLower); + if (!titleRank) { + return 0; + } } - if (this.urlParts) { + + if (this.useUrl && this.urlParts) { if (entry.url) { const entryUrlParts = urlPartsRegex.exec(entry.url.toLowerCase()); if (entryUrlParts) { const [, scheme, domain, path] = entryUrlParts; const [, thisScheme, thisDomain, thisPath] = this.urlParts; - if (domain === thisDomain || thisDomain.indexOf('.' + domain) > 0) { + if ( + domain === thisDomain || + (this.subdomains && thisDomain.indexOf('.' + domain) > 0) + ) { if (domain === thisDomain) { - rank += 20; + urlRank += 20; } else { - rank += 10; + urlRank += 10; } if (path === thisPath) { - rank += 10; + urlRank += 10; } else if (path && thisPath) { if (path.lastIndexOf(thisPath, 0) === 0) { - rank += 5; + urlRank += 5; } else if (thisPath.lastIndexOf(path, 0) === 0) { - rank += 3; + urlRank += 3; } } if (scheme === thisScheme) { - rank += 1; + urlRank += 1; } } else { if (entry.searchText.indexOf(this.urlLower) >= 0) { // the url is in some field; include it - rank += 5; + urlRank += 5; } else { - // another domain; don't show this record at all, ignore title match - return 0; + // another domain; don't show this record at all } } } } else { if (entry.searchText.indexOf(this.urlLower) >= 0) { // the url is in some field; include it - rank += 5; + urlRank += 5; } } } - return rank; + + if (this.useTitle && !titleRank) { + return 0; + } + if (this.useUrl && !urlRank) { + return 0; + } + + return titleRank + urlRank; } } diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index 2509343f..3c6bf329 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -803,5 +803,8 @@ "selectEntryHeader": "Select entry", "selectEntryEnterHint": "use the highlighted entry", - "selectEntryEscHint": "cancel" + "selectEntryEscHint": "cancel", + "selectEntryTypingHint": "Start typing to filter", + "selectEntryContains": "Contains text", + "selectEntrySubdomains": "Subdomains" } diff --git a/app/scripts/util/formatting/url-format.js b/app/scripts/util/formatting/url-format.js index 11ae8f3c..331ea00d 100644 --- a/app/scripts/util/formatting/url-format.js +++ b/app/scripts/util/formatting/url-format.js @@ -2,6 +2,7 @@ const UrlFormat = { multiSlashRegex: /\/{2,}/g, lastPartRegex: /[\/\\]?[^\/\\]+$/, kdbxEndRegex: /\.kdbx$/i, + maxShortPresentableUrlLength: 60, getDataFileName(url) { const ix = url.lastIndexOf('/'); @@ -35,6 +36,36 @@ const UrlFormat = { return Object.entries(params) .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) .join('&'); + }, + + presentAsShortUrl(url) { + if (url.length <= this.maxShortPresentableUrlLength) { + return url; + } + + const [beforeHash] = url.split('#', 1); + if (beforeHash.length <= this.maxShortPresentableUrlLength) { + return beforeHash + '#…'; + } + + const [beforeQuestionMark] = url.split('?', 1); + if (beforeQuestionMark.length <= this.maxShortPresentableUrlLength) { + return beforeQuestionMark + '?…'; + } + + const parsed = new URL(beforeQuestionMark); + const pathParts = parsed.pathname.split('/'); + + while (pathParts.length > 1) { + pathParts.pop(); + parsed.pathname = pathParts.join('/'); + const res = parsed.toString(); + if (res.length < this.maxShortPresentableUrlLength) { + return res + '/…'; + } + } + + return parsed + '…'; } }; diff --git a/app/scripts/views/select/select-entry-view.js b/app/scripts/views/select/select-entry-view.js index fc9fa219..8360624e 100644 --- a/app/scripts/views/select/select-entry-view.js +++ b/app/scripts/views/select/select-entry-view.js @@ -6,6 +6,7 @@ import { Keys } from 'const/keys'; import { AppSettingsModel } from 'models/app-settings-model'; import { EntryPresenter } from 'presenters/entry-presenter'; import { StringFormat } from 'util/formatting/string-format'; +import { UrlFormat } from 'util/formatting/url-format'; import { Locale } from 'util/locale'; import { Scrollable } from 'framework/views/scrollable'; import { DropdownView } from 'views/dropdown-view'; @@ -23,7 +24,8 @@ class SelectEntryView extends View { events = { 'click .select-entry__header-filter-clear': 'clearFilterText', 'click .select-entry__item': 'itemClicked', - 'contextmenu .select-entry__item': 'itemRightClicked' + 'contextmenu .select-entry__item': 'itemRightClicked', + 'click .select-entry__filter': 'filterClicked' }; result = null; @@ -73,10 +75,13 @@ class SelectEntryView extends View { render() { const noColor = AppSettingsModel.colorfulIcons ? '' : 'grayscale'; - this.entries = this.model.filter.getEntries(); - this.result = this.entries[0]; - const presenter = new EntryPresenter(null, noColor, this.result && this.result.id); + this.entries = this.model.filter.getEntries(); + if (!this.result || !this.entries.includes(this.result)) { + this.result = this.entries[0]; + } + + const presenter = new EntryPresenter(null, noColor, this.result?.id); presenter.itemOptions = this.model.itemOptions; let itemsHtml = ''; @@ -86,10 +91,43 @@ class SelectEntryView extends View { itemsHtml += itemTemplate(presenter, DefaultTemplateOptions); }); + const filters = []; + if (this.model.filter.url) { + const shortUrl = UrlFormat.presentAsShortUrl(this.model.filter.url); + filters.push({ + id: 'url', + type: StringFormat.capFirst(Locale.website), + text: shortUrl, + active: this.model.filter.useUrl + }); + + filters.push({ + id: 'subdomains', + type: StringFormat.capFirst(Locale.selectEntrySubdomains), + active: this.model.filter.useUrl && this.model.filter.subdomains + }); + } + if (this.model.filter.title) { + filters.push({ + id: 'title', + type: StringFormat.capFirst(Locale.title), + text: this.model.filter.title, + active: this.model.filter.useTitle + }); + } + if (this.model.filter.text) { + filters.push({ + id: 'text', + type: StringFormat.capFirst(Locale.selectEntryContains), + text: this.model.filter.text, + active: true + }); + } + super.render({ isAutoType: this.model.isAutoType, - filterText: this.model.filter.text, topMessage: this.model.topMessage, + filters, itemsHtml, actionSymbol: Shortcuts.actionShortcutSymbol(true), altSymbol: Shortcuts.altShortcutSymbol(true), @@ -192,18 +230,10 @@ class SelectEntryView extends View { backSpacePressed() { if (this.model.filter.text) { - const input = this.el.querySelector('.select-entry__header-filter-input'); - if (input.selectionStart < input.selectionEnd) { - this.model.filter.text = - this.model.filter.text.substr(0, input.selectionStart) + - this.model.filter.text.substr(input.selectionEnd); - input.selectionStart = input.selectionEnd = 0; - } else { - this.model.filter.text = this.model.filter.text.substr( - 0, - this.model.filter.text.length - 1 - ); - } + this.model.filter.text = this.model.filter.text.substr( + 0, + this.model.filter.text.length - 1 + ); this.render(); } } @@ -336,6 +366,34 @@ class SelectEntryView extends View { }); }); } + + filterClicked(e) { + const filterEl = e.target.closest('.select-entry__filter'); + const filter = filterEl.dataset.filter; + const active = filterEl.dataset.active !== 'true'; + + switch (filter) { + case 'url': + this.model.filter.useUrl = active; + break; + case 'subdomains': + this.model.filter.subdomains = active; + if (active) { + this.model.filter.useUrl = true; + } + break; + case 'title': + this.model.filter.useTitle = active; + break; + case 'text': + if (!active) { + this.model.filter.text = ''; + } + break; + } + + this.render(); + } } Object.assign(SelectEntryView.prototype, Scrollable); diff --git a/app/styles/areas/_select-entry.scss b/app/styles/areas/_select-entry.scss index 3b103ee4..fd1cadce 100644 --- a/app/styles/areas/_select-entry.scss +++ b/app/styles/areas/_select-entry.scss @@ -27,19 +27,6 @@ white-space: nowrap; padding-right: $base-padding-h; } - &-filter { - flex: auto 0; - position: relative; - } - &-filter-input { - width: 200px; - } - &-filter-clear { - cursor: pointer; - position: absolute; - right: 0.7em; - top: 0.5em; - } } &__message { display: flex; @@ -129,4 +116,32 @@ &__empty-title { align-self: center; } + &__filters { + display: flex; + flex-direction: row; + align-items: flex-start; + flex-wrap: wrap; + } + &__filter { + display: flex; + margin-bottom: $small-spacing; + margin-right: $small-spacing; + border-radius: var(--button-border-radius); + background-color: var(--unselected-background-color); + cursor: pointer; + &:hover { + background-color: var(--unselected-background-color-hover); + } + &-text, + &-icon { + padding: $base-padding; + } + &-check { + font-size: 1.2em; + padding: 0.35em 0 0 $base-padding-h; + } + &-text { + padding-left: 0; + } + } } diff --git a/app/styles/base/_icon-font.scss b/app/styles/base/_icon-font.scss index a1595468..ea92578a 100644 --- a/app/styles/base/_icon-font.scss +++ b/app/styles/base/_icon-font.scss @@ -204,3 +204,4 @@ $fa-var-window-maximize: next-fa-glyph(); $fa-var-download: next-fa-glyph(); $fa-var-exchange-alt: next-fa-glyph(); $fa-var-folder-plus: next-fa-glyph(); +$fa-var-filter: next-fa-glyph(); diff --git a/app/styles/base/_theme-vars.scss b/app/styles/base/_theme-vars.scss index 4e915444..c6d6c325 100644 --- a/app/styles/base/_theme-vars.scss +++ b/app/styles/base/_theme-vars.scss @@ -53,6 +53,10 @@ intermediate-pressed-background-color: mix(map-get($t, medium-color), map-get($t, background-color), 2.6%), disabled-background-color: shade(map-get($t, background-color), 5%), + unselected-background-color: + mix(map-get($t, medium-color), map-get($t, background-color), 9%), + unselected-background-color-hover: + mix(map-get($t, medium-color), map-get($t, background-color), 14%), action-background-color-focus: shade(map-get($t, action-color), 20%), action-background-color-focus-tr: rgba(shade(map-get($t, action-color), 20%), 0.1), error-background-color-focus: shade(map-get($t, error-color), 20%), diff --git a/app/templates/select/select-entry.hbs b/app/templates/select/select-entry.hbs index 10ca8744..55594072 100644 --- a/app/templates/select/select-entry.hbs +++ b/app/templates/select/select-entry.hbs @@ -16,18 +16,25 @@ {{else}}
{{keyEnter}}: {{res 'selectEntryEnterHint'}}
{{keyEsc}}: {{res 'selectEntryEscHint'}}
+
{{res 'selectEntryTypingHint'}}
{{/if}} - {{#if filterText}} -
- - -
- {{/if}}
{{topMessage}}
+
+ {{#each filters as |filter|}} +
+ + +
{{filter.type}}{{#if filter.text}}: {{/if}}{{filter.text}}
+
+ {{/each}} +
{{#if itemsHtml}} diff --git a/release-notes.md b/release-notes.md index cda3d22f..3b0e7fdb 100644 --- a/release-notes.md +++ b/release-notes.md @@ -8,6 +8,7 @@ Release notes `+` better Touch ID error messages `-` legacy auto-type removed `+` displaying the reason why unlock is requested +`+` filters on the auto-type entry selection screen `+` adding multiple websites to one entry `-` fixed a crash after disabling USB devices on Linux `+` tightened content security policy diff --git a/test/src/util/formatting/url-format.js b/test/src/util/formatting/url-format.js index 76bb8272..12ece12f 100644 --- a/test/src/util/formatting/url-format.js +++ b/test/src/util/formatting/url-format.js @@ -41,4 +41,34 @@ describe('UrlFormat', () => { }) ).to.eql('hello=world&data=%3D%20%26'); }); + + it('should remove anchor for short urls', () => { + expect( + UrlFormat.presentAsShortUrl('https://example.com/path?query=1#anchor' + '0'.repeat(100)) + ).to.eql('https://example.com/path?query=1#…'); + }); + + it('should remove query string for short urls', () => { + expect( + UrlFormat.presentAsShortUrl( + 'https://example.com/path?query=' + '1'.repeat(100) + '#anchor' + '0'.repeat(100) + ) + ).to.eql('https://example.com/path?…'); + }); + + it('should remove query parts of path for short urls', () => { + expect( + UrlFormat.presentAsShortUrl( + 'https://example.com/path/' + '1'.repeat(100) + '/' + '0'.repeat(100) + ) + ).to.eql('https://example.com/path/…'); + }); + + it('should not remove domain for short urls', () => { + expect( + UrlFormat.presentAsShortUrl( + 'https://example' + '0'.repeat(100) + '.com/' + '1'.repeat(100) + ) + ).to.eql('https://example' + '0'.repeat(100) + '.com/…'); + }); });