entry selection filters

This commit is contained in:
antelle 2021-04-28 17:34:48 +02:00
parent 95857450c2
commit 732c35625b
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
10 changed files with 225 additions and 54 deletions

View File

@ -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;
}
}

View File

@ -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"
}

View File

@ -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 + '…';
}
};

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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();

View File

@ -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%),

View File

@ -16,18 +16,25 @@
{{else}}
<div class="select-entry__hint-text"><span class="shortcut">{{keyEnter}}</span>: {{res 'selectEntryEnterHint'}}</div>
<div class="select-entry__hint-text"><span class="shortcut">{{keyEsc}}</span>: {{res 'selectEntryEscHint'}}</div>
<div class="select-entry__hint-text">{{res 'selectEntryTypingHint'}}</div>
{{/if}}
</div>
{{#if filterText}}
<div class="select-entry__header-filter" id="select-entry__header-filter">
<input type="text" readonly value="{{filterText}}" class="select-entry__header-filter-input" />
<i class="select-entry__header-filter-clear fa fa-times"></i>
</div>
{{/if}}
</div>
<div class="select-entry__message">
<div class="select-entry__message-text">{{topMessage}}</div>
</div>
<div class="select-entry__filters">
{{#each filters as |filter|}}
<div class="select-entry__filter {{#if filter.active}}select-entry__filter--active{{/if}}"
data-filter="{{filter.id}}"
data-active="{{filter.active}}"
>
<i class="fa {{#if filter.active}}fa-check-square-o{{else}}fa-square-o{{/if}} select-entry__filter-check"></i>
<i class="fa fa-filter select-entry__filter-icon"></i>
<div class="select-entry__filter-text">{{filter.type}}{{#if filter.text}}: {{/if}}{{filter.text}}</div>
</div>
{{/each}}
</div>
<div class="select-entry__items">
<div class="scroller">
{{#if itemsHtml}}

View File

@ -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

View File

@ -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/…');
});
});