mirror of https://github.com/keeweb/keeweb.git
entry selection filters
This commit is contained in:
parent
95857450c2
commit
732c35625b
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 + '…';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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%),
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/…');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue