feat: add models package
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { createNoteWithContent } from '../../Utilities/Test/SpecUtils'
|
||||
import { ItemCollection } from '../Collection/Item/ItemCollection'
|
||||
import { SNNote } from '../../Syncable/Note/Note'
|
||||
import { itemsMatchingOptions } from './Search/SearchUtilities'
|
||||
import { FilterDisplayOptions } from './DisplayOptions'
|
||||
|
||||
describe('item display options', () => {
|
||||
const collectionWithNotes = function (titles: (string | undefined)[] = [], bodies: string[] = []) {
|
||||
const collection = new ItemCollection()
|
||||
const notes: SNNote[] = []
|
||||
titles.forEach((title, index) => {
|
||||
notes.push(
|
||||
createNoteWithContent({
|
||||
title: title,
|
||||
text: bodies[index],
|
||||
}),
|
||||
)
|
||||
})
|
||||
collection.set(notes)
|
||||
return collection
|
||||
}
|
||||
|
||||
it('string query title', () => {
|
||||
const query = 'foo'
|
||||
|
||||
const options: FilterDisplayOptions = {
|
||||
searchQuery: { query: query, includeProtectedNoteText: true },
|
||||
}
|
||||
const collection = collectionWithNotes(['hello', 'fobar', 'foobar', 'foo'])
|
||||
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('string query text', async function () {
|
||||
const query = 'foo'
|
||||
const options: FilterDisplayOptions = {
|
||||
searchQuery: { query: query, includeProtectedNoteText: true },
|
||||
}
|
||||
const collection = collectionWithNotes(
|
||||
[undefined, undefined, undefined, undefined],
|
||||
['hello', 'fobar', 'foobar', 'foo'],
|
||||
)
|
||||
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('string query title and text', async function () {
|
||||
const query = 'foo'
|
||||
const options: FilterDisplayOptions = {
|
||||
searchQuery: { query: query, includeProtectedNoteText: true },
|
||||
}
|
||||
const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar'])
|
||||
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
25
packages/models/src/Domain/Runtime/Display/DisplayOptions.ts
Normal file
25
packages/models/src/Domain/Runtime/Display/DisplayOptions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { SmartView } from '../../Syncable/SmartView'
|
||||
import { SNTag } from '../../Syncable/Tag'
|
||||
import { CollectionSortDirection, CollectionSortProperty } from '../Collection/CollectionSort'
|
||||
import { SearchQuery } from './Search/Types'
|
||||
import { DisplayControllerCustomFilter } from './Types'
|
||||
|
||||
export type DisplayOptions = FilterDisplayOptions & DisplayControllerOptions
|
||||
|
||||
export interface FilterDisplayOptions {
|
||||
tags?: SNTag[]
|
||||
views?: SmartView[]
|
||||
searchQuery?: SearchQuery
|
||||
includePinned?: boolean
|
||||
includeProtected?: boolean
|
||||
includeTrashed?: boolean
|
||||
includeArchived?: boolean
|
||||
}
|
||||
|
||||
export interface DisplayControllerOptions {
|
||||
sortBy: CollectionSortProperty
|
||||
sortDirection: CollectionSortDirection
|
||||
hiddenContentTypes?: ContentType[]
|
||||
customFilter?: DisplayControllerCustomFilter
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { DecryptedItem } from '../../Abstract/Item'
|
||||
import { SNTag } from '../../Syncable/Tag'
|
||||
import { CompoundPredicate } from '../Predicate/CompoundPredicate'
|
||||
import { ItemWithTags } from './Search/ItemWithTags'
|
||||
import { itemMatchesQuery, itemPassesFilters } from './Search/SearchUtilities'
|
||||
import { ItemFilter, ReferenceLookupCollection, SearchableDecryptedItem } from './Search/Types'
|
||||
import { FilterDisplayOptions } from './DisplayOptions'
|
||||
|
||||
export function computeUnifiedFilterForDisplayOptions(
|
||||
options: FilterDisplayOptions,
|
||||
collection: ReferenceLookupCollection,
|
||||
): ItemFilter {
|
||||
const filters = computeFiltersForDisplayOptions(options, collection)
|
||||
|
||||
return (item: SearchableDecryptedItem) => {
|
||||
return itemPassesFilters(item, filters)
|
||||
}
|
||||
}
|
||||
|
||||
export function computeFiltersForDisplayOptions(
|
||||
options: FilterDisplayOptions,
|
||||
collection: ReferenceLookupCollection,
|
||||
): ItemFilter[] {
|
||||
const filters: ItemFilter[] = []
|
||||
|
||||
let viewsPredicate: CompoundPredicate<DecryptedItem> | undefined = undefined
|
||||
|
||||
if (options.views && options.views.length > 0) {
|
||||
const compoundPredicate = new CompoundPredicate(
|
||||
'and',
|
||||
options.views.map((t) => t.predicate),
|
||||
)
|
||||
viewsPredicate = compoundPredicate
|
||||
|
||||
filters.push((item) => {
|
||||
if (compoundPredicate.keypathIncludesString('tags')) {
|
||||
const noteWithTags = ItemWithTags.Create(
|
||||
item.payload,
|
||||
item,
|
||||
collection.elementsReferencingElement(item, ContentType.Tag) as SNTag[],
|
||||
)
|
||||
return compoundPredicate.matchesItem(noteWithTags)
|
||||
} else {
|
||||
return compoundPredicate.matchesItem(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
for (const tag of options.tags) {
|
||||
filters.push((item) => tag.isReferencingItem(item))
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includePinned === false && !viewsPredicate?.keypathIncludesString('pinned')) {
|
||||
filters.push((item) => !item.pinned)
|
||||
}
|
||||
|
||||
if (options.includeProtected === false && !viewsPredicate?.keypathIncludesString('protected')) {
|
||||
filters.push((item) => !item.protected)
|
||||
}
|
||||
|
||||
if (options.includeTrashed === false && !viewsPredicate?.keypathIncludesString('trashed')) {
|
||||
filters.push((item) => !item.trashed)
|
||||
}
|
||||
|
||||
if (options.includeArchived === false && !viewsPredicate?.keypathIncludesString('archived')) {
|
||||
filters.push((item) => !item.archived)
|
||||
}
|
||||
|
||||
if (options.searchQuery) {
|
||||
const query = options.searchQuery
|
||||
filters.push((item) => itemMatchesQuery(item, query, collection))
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { CreateItemDelta } from './../Index/ItemDelta'
|
||||
import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload'
|
||||
import { createFile, createNote, createTag, mockUuid, pinnedContent } from './../../Utilities/Test/SpecUtils'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { DeletedItem, EncryptedItem } from '../../Abstract/Item'
|
||||
import { EncryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload'
|
||||
import { createNoteWithContent } from '../../Utilities/Test/SpecUtils'
|
||||
import { ItemCollection } from './../Collection/Item/ItemCollection'
|
||||
import { ItemDisplayController } from './ItemDisplayController'
|
||||
import { SNNote } from '../../Syncable/Note'
|
||||
|
||||
describe('item display controller', () => {
|
||||
it('should sort items', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
const noteB = createNoteWithContent({ title: 'b' })
|
||||
collection.set([noteA, noteB])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
expect(controller.items()[0]).toEqual(noteA)
|
||||
expect(controller.items()[1]).toEqual(noteB)
|
||||
|
||||
controller.setDisplayOptions({ sortBy: 'title', sortDirection: 'dsc' })
|
||||
|
||||
expect(controller.items()[0]).toEqual(noteB)
|
||||
expect(controller.items()[1]).toEqual(noteA)
|
||||
})
|
||||
|
||||
it('should filter items', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
const noteB = createNoteWithContent({ title: 'b' })
|
||||
collection.set([noteA, noteB])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
controller.setDisplayOptions({
|
||||
customFilter: (note) => {
|
||||
return note.title !== 'a'
|
||||
},
|
||||
})
|
||||
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
expect(controller.items()[0].title).toEqual('b')
|
||||
})
|
||||
|
||||
it('should resort items after collection change', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
collection.set([noteA])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
|
||||
const noteB = createNoteWithContent({ title: 'b' })
|
||||
|
||||
const delta = CreateItemDelta({ changed: [noteB] })
|
||||
collection.onChange(delta)
|
||||
controller.onCollectionChange(delta)
|
||||
|
||||
expect(controller.items()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should not display encrypted items', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = new EncryptedItem(
|
||||
new EncryptedPayload({
|
||||
uuid: mockUuid(),
|
||||
content_type: ContentType.Note,
|
||||
content: '004:...',
|
||||
enc_item_key: '004:...',
|
||||
items_key_id: mockUuid(),
|
||||
errorDecrypting: true,
|
||||
waitingForKey: false,
|
||||
...PayloadTimestampDefaults(),
|
||||
}),
|
||||
)
|
||||
collection.set([noteA])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
expect(controller.items()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('pinned items should come first', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
const noteB = createNoteWithContent({ title: 'b' })
|
||||
collection.set([noteA, noteB])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
expect(controller.items()[0]).toEqual(noteA)
|
||||
expect(controller.items()[1]).toEqual(noteB)
|
||||
|
||||
expect(collection.all()).toHaveLength(2)
|
||||
|
||||
const pinnedNoteB = new SNNote(
|
||||
noteB.payload.copy({
|
||||
content: {
|
||||
...noteB.content,
|
||||
...pinnedContent(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(pinnedNoteB.pinned).toBeTruthy()
|
||||
|
||||
const delta = CreateItemDelta({ changed: [pinnedNoteB] })
|
||||
collection.onChange(delta)
|
||||
controller.onCollectionChange(delta)
|
||||
|
||||
expect(controller.items()[0]).toEqual(pinnedNoteB)
|
||||
expect(controller.items()[1]).toEqual(noteA)
|
||||
})
|
||||
|
||||
it('should not display deleted items', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
collection.set([noteA])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
const deletedItem = new DeletedItem(
|
||||
new DeletedPayload({
|
||||
...noteA.payload,
|
||||
content: undefined,
|
||||
deleted: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const delta = CreateItemDelta({ changed: [deletedItem] })
|
||||
collection.onChange(delta)
|
||||
controller.onCollectionChange(delta)
|
||||
|
||||
expect(controller.items()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discarding elements should remove from display', () => {
|
||||
const collection = new ItemCollection()
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
collection.set([noteA])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
const delta = CreateItemDelta({ discarded: [noteA] as unknown as DeletedItem[] })
|
||||
collection.onChange(delta)
|
||||
controller.onCollectionChange(delta)
|
||||
|
||||
expect(controller.items()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should ignore items not matching content type on construction', () => {
|
||||
const collection = new ItemCollection()
|
||||
const note = createNoteWithContent({ title: 'a' })
|
||||
const tag = createTag()
|
||||
collection.set([note, tag])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should ignore items not matching content type on sort change', () => {
|
||||
const collection = new ItemCollection()
|
||||
const note = createNoteWithContent({ title: 'a' })
|
||||
const tag = createTag()
|
||||
collection.set([note, tag])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
controller.setDisplayOptions({ sortBy: 'created_at', sortDirection: 'asc' })
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should ignore collection deltas with items not matching content types', () => {
|
||||
const collection = new ItemCollection()
|
||||
const note = createNoteWithContent({ title: 'a' })
|
||||
collection.set([note])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
const tag = createTag()
|
||||
|
||||
const delta = CreateItemDelta({ inserted: [tag], changed: [note] })
|
||||
collection.onChange(delta)
|
||||
controller.onCollectionChange(delta)
|
||||
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should display compound item types', () => {
|
||||
const collection = new ItemCollection()
|
||||
const note = createNoteWithContent({ title: 'Z' })
|
||||
const file = createFile('A')
|
||||
collection.set([note, file])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note, ContentType.File], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
expect(controller.items()[0]).toEqual(file)
|
||||
expect(controller.items()[1]).toEqual(note)
|
||||
|
||||
controller.setDisplayOptions({ sortBy: 'title', sortDirection: 'dsc' })
|
||||
|
||||
expect(controller.items()[0]).toEqual(note)
|
||||
expect(controller.items()[1]).toEqual(file)
|
||||
})
|
||||
|
||||
it('should hide hidden types', () => {
|
||||
const collection = new ItemCollection()
|
||||
const note = createNote()
|
||||
const file = createFile()
|
||||
collection.set([note, file])
|
||||
|
||||
const controller = new ItemDisplayController(collection, [ContentType.Note, ContentType.File], {
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
|
||||
expect(controller.items()).toHaveLength(2)
|
||||
|
||||
controller.setDisplayOptions({ hiddenContentTypes: [ContentType.File] })
|
||||
|
||||
expect(controller.items()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { compareValues } from '@standardnotes/utils'
|
||||
import { isDeletedItem, isEncryptedItem } from '../../Abstract/Item'
|
||||
import { ItemDelta } from '../Index/ItemDelta'
|
||||
import { DisplayControllerOptions } from './DisplayOptions'
|
||||
import { sortTwoItems } from './SortTwoItems'
|
||||
import { UuidToSortedPositionMap, DisplayItem, ReadonlyItemCollection } from './Types'
|
||||
|
||||
export class ItemDisplayController<I extends DisplayItem> {
|
||||
private sortMap: UuidToSortedPositionMap = {}
|
||||
private sortedItems: I[] = []
|
||||
private needsSort = true
|
||||
|
||||
constructor(
|
||||
private readonly collection: ReadonlyItemCollection,
|
||||
public readonly contentTypes: ContentType[],
|
||||
private options: DisplayControllerOptions,
|
||||
) {
|
||||
this.filterThenSortElements(this.collection.all(this.contentTypes) as I[])
|
||||
}
|
||||
|
||||
public items(): I[] {
|
||||
return this.sortedItems
|
||||
}
|
||||
|
||||
setDisplayOptions(displayOptions: Partial<DisplayControllerOptions>): void {
|
||||
this.options = { ...this.options, ...displayOptions }
|
||||
this.needsSort = true
|
||||
|
||||
this.filterThenSortElements(this.collection.all(this.contentTypes) as I[])
|
||||
}
|
||||
|
||||
onCollectionChange(delta: ItemDelta): void {
|
||||
const items = [...delta.changed, ...delta.inserted, ...delta.discarded].filter((i) =>
|
||||
this.contentTypes.includes(i.content_type),
|
||||
)
|
||||
this.filterThenSortElements(items as I[])
|
||||
}
|
||||
|
||||
private filterThenSortElements(elements: I[]): void {
|
||||
for (const element of elements) {
|
||||
const previousIndex = this.sortMap[element.uuid]
|
||||
const previousElement = previousIndex != undefined ? this.sortedItems[previousIndex] : undefined
|
||||
|
||||
const remove = () => {
|
||||
if (previousIndex != undefined) {
|
||||
delete this.sortMap[element.uuid]
|
||||
|
||||
/** We don't yet remove the element directly from the array, since mutating
|
||||
* the array inside a loop could render all other upcoming indexes invalid */
|
||||
;(this.sortedItems[previousIndex] as unknown) = undefined
|
||||
|
||||
/** Since an element is being removed from the array, we need to recompute
|
||||
* the new positions for elements that are staying */
|
||||
this.needsSort = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isDeletedItem(element) || isEncryptedItem(element)) {
|
||||
remove()
|
||||
continue
|
||||
}
|
||||
|
||||
const passes = !this.collection.has(element.uuid)
|
||||
? false
|
||||
: this.options.hiddenContentTypes?.includes(element.content_type)
|
||||
? false
|
||||
: this.options.customFilter
|
||||
? this.options.customFilter(element)
|
||||
: true
|
||||
|
||||
if (passes) {
|
||||
if (previousElement != undefined) {
|
||||
/** Check to see if the element has changed its sort value. If so, we need to re-sort. */
|
||||
const previousValue = previousElement[this.options.sortBy]
|
||||
|
||||
const newValue = element[this.options.sortBy]
|
||||
|
||||
/** Replace the current element with the new one. */
|
||||
this.sortedItems[previousIndex] = element
|
||||
|
||||
/** If the pinned status of the element has changed, it needs to be resorted */
|
||||
const pinChanged = previousElement.pinned !== element.pinned
|
||||
|
||||
if (!compareValues(previousValue, newValue) || pinChanged) {
|
||||
/** Needs resort because its re-sort value has changed,
|
||||
* and thus its position might change */
|
||||
this.needsSort = true
|
||||
}
|
||||
} else {
|
||||
/** Has not yet been inserted */
|
||||
this.sortedItems.push(element)
|
||||
|
||||
/** Needs re-sort because we're just pushing the element to the end here */
|
||||
this.needsSort = true
|
||||
}
|
||||
} else {
|
||||
/** Doesn't pass filter, remove from sorted and filtered */
|
||||
remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.needsSort) {
|
||||
this.needsSort = false
|
||||
this.resortItems()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resort the sortedItems array, and update the saved positions */
|
||||
private resortItems() {
|
||||
const resorted = this.sortedItems.sort((a, b) => {
|
||||
return sortTwoItems(a, b, this.options.sortBy, this.options.sortDirection)
|
||||
})
|
||||
|
||||
/**
|
||||
* Now that resorted contains the sorted elements (but also can contain undefined element)
|
||||
* we create another array that filters out any of the undefinedes. We also keep track of the
|
||||
* current index while we loop and set that in the this.sortMap.
|
||||
* */
|
||||
const results = []
|
||||
let currentIndex = 0
|
||||
|
||||
/** @O(n) */
|
||||
for (const element of resorted) {
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push(element)
|
||||
|
||||
this.sortMap[element.uuid] = currentIndex
|
||||
|
||||
currentIndex++
|
||||
}
|
||||
|
||||
this.sortedItems = results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SearchableDecryptedItem } from './Types'
|
||||
import { ItemContent } from '../../../Abstract/Content/ItemContent'
|
||||
import { DecryptedItem } from '../../../Abstract/Item'
|
||||
import { DecryptedPayloadInterface } from '../../../Abstract/Payload/Interfaces/DecryptedPayload'
|
||||
import { SNTag } from '../../../Syncable/Tag'
|
||||
|
||||
interface ItemWithTagsContent extends ItemContent {
|
||||
tags: SNTag[]
|
||||
}
|
||||
|
||||
export class ItemWithTags extends DecryptedItem implements SearchableDecryptedItem {
|
||||
constructor(
|
||||
payload: DecryptedPayloadInterface<ItemWithTagsContent>,
|
||||
private item: SearchableDecryptedItem,
|
||||
public readonly tags?: SNTag[],
|
||||
) {
|
||||
super(payload)
|
||||
this.tags = tags || payload.content.tags
|
||||
}
|
||||
|
||||
static Create(payload: DecryptedPayloadInterface<ItemContent>, item: SearchableDecryptedItem, tags?: SNTag[]) {
|
||||
return new ItemWithTags(payload as DecryptedPayloadInterface<ItemWithTagsContent>, item, tags)
|
||||
}
|
||||
|
||||
get tagsCount(): number {
|
||||
return this.tags?.length || 0
|
||||
}
|
||||
|
||||
get title(): string | undefined {
|
||||
return this.item.title
|
||||
}
|
||||
|
||||
get text(): string | undefined {
|
||||
return this.item.text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { SNTag } from '../../../Syncable/Tag'
|
||||
import { FilterDisplayOptions } from '../DisplayOptions'
|
||||
import { computeFiltersForDisplayOptions } from '../DisplayOptionsToFilters'
|
||||
import { SearchableItem } from './SearchableItem'
|
||||
import { ReferenceLookupCollection, ItemFilter, SearchQuery, SearchableDecryptedItem } from './Types'
|
||||
|
||||
enum MatchResult {
|
||||
None = 0,
|
||||
Title = 1,
|
||||
Text = 2,
|
||||
TitleAndText = Title + Text,
|
||||
Uuid = 5,
|
||||
}
|
||||
|
||||
export function itemsMatchingOptions(
|
||||
options: FilterDisplayOptions,
|
||||
fromItems: SearchableDecryptedItem[],
|
||||
collection: ReferenceLookupCollection,
|
||||
): SearchableItem[] {
|
||||
const filters = computeFiltersForDisplayOptions(options, collection)
|
||||
|
||||
return fromItems.filter((item) => {
|
||||
return itemPassesFilters(item, filters)
|
||||
})
|
||||
}
|
||||
export function itemPassesFilters(item: SearchableDecryptedItem, filters: ItemFilter[]) {
|
||||
for (const filter of filters) {
|
||||
if (!filter(item)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function itemMatchesQuery(
|
||||
itemToMatch: SearchableDecryptedItem,
|
||||
searchQuery: SearchQuery,
|
||||
collection: ReferenceLookupCollection,
|
||||
): boolean {
|
||||
const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.Tag) as SNTag[]
|
||||
const someTagsMatches = itemTags.some((tag) => matchResultForStringQuery(tag, searchQuery.query) !== MatchResult.None)
|
||||
|
||||
if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) {
|
||||
const match = matchResultForStringQuery(itemToMatch, searchQuery.query)
|
||||
return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches
|
||||
}
|
||||
|
||||
return matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches
|
||||
}
|
||||
|
||||
function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult {
|
||||
if (searchString.length === 0) {
|
||||
return MatchResult.TitleAndText
|
||||
}
|
||||
|
||||
const title = item.title?.toLowerCase()
|
||||
const text = item.text?.toLowerCase()
|
||||
const lowercaseText = searchString.toLowerCase()
|
||||
const words = lowercaseText.split(' ')
|
||||
const quotedText = stringBetweenQuotes(lowercaseText)
|
||||
|
||||
if (quotedText) {
|
||||
return (
|
||||
(title?.includes(quotedText) ? MatchResult.Title : MatchResult.None) +
|
||||
(text?.includes(quotedText) ? MatchResult.Text : MatchResult.None)
|
||||
)
|
||||
}
|
||||
|
||||
if (stringIsUuid(lowercaseText)) {
|
||||
return item.uuid === lowercaseText ? MatchResult.Uuid : MatchResult.None
|
||||
}
|
||||
|
||||
const matchesTitle =
|
||||
title &&
|
||||
words.every((word) => {
|
||||
return title.indexOf(word) >= 0
|
||||
})
|
||||
|
||||
const matchesBody =
|
||||
text &&
|
||||
words.every((word) => {
|
||||
return text.indexOf(word) >= 0
|
||||
})
|
||||
|
||||
return (matchesTitle ? MatchResult.Title : 0) + (matchesBody ? MatchResult.Text : 0)
|
||||
}
|
||||
|
||||
function stringBetweenQuotes(text: string) {
|
||||
const matches = text.match(/"(.*?)"/)
|
||||
return matches ? matches[1] : null
|
||||
}
|
||||
|
||||
function stringIsUuid(text: string) {
|
||||
const matches = text.match(/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/)
|
||||
return matches ? true : false
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SearchableItem {
|
||||
uuid: string
|
||||
title?: string
|
||||
text?: string
|
||||
}
|
||||
16
packages/models/src/Domain/Runtime/Display/Search/Types.ts
Normal file
16
packages/models/src/Domain/Runtime/Display/Search/Types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ItemCollection } from './../../Collection/Item/ItemCollection'
|
||||
import { DecryptedItemInterface } from '../../../Abstract/Item'
|
||||
import { SearchableItem } from './SearchableItem'
|
||||
|
||||
export type SearchQuery = {
|
||||
query: string
|
||||
includeProtectedNoteText: boolean
|
||||
}
|
||||
|
||||
export interface ReferenceLookupCollection {
|
||||
elementsReferencingElement: ItemCollection['elementsReferencingElement']
|
||||
}
|
||||
|
||||
export type SearchableDecryptedItem = SearchableItem & DecryptedItemInterface
|
||||
|
||||
export type ItemFilter = (item: SearchableDecryptedItem) => boolean
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SortLeftFirst, SortRightFirst, sortTwoItems } from './SortTwoItems'
|
||||
import { createNoteWithContent } from '../../Utilities/Test/SpecUtils'
|
||||
import { SNNote } from '../../Syncable/Note'
|
||||
|
||||
describe('sort two items', () => {
|
||||
it('should sort correctly by dates', () => {
|
||||
const noteA = createNoteWithContent({}, new Date(0))
|
||||
const noteB = createNoteWithContent({}, new Date(1))
|
||||
|
||||
expect(sortTwoItems(noteA, noteB, 'created_at', 'asc')).toEqual(SortLeftFirst)
|
||||
expect(sortTwoItems(noteA, noteB, 'created_at', 'dsc')).toEqual(SortRightFirst)
|
||||
})
|
||||
|
||||
it('should sort by title', () => {
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
const noteB = createNoteWithContent({ title: 'b' })
|
||||
|
||||
expect(sortTwoItems(noteA, noteB, 'title', 'asc')).toEqual(SortLeftFirst)
|
||||
expect(sortTwoItems(noteA, noteB, 'title', 'dsc')).toEqual(SortRightFirst)
|
||||
})
|
||||
|
||||
it('should sort correctly by title and pinned', () => {
|
||||
const noteA = createNoteWithContent({ title: 'a' })
|
||||
const noteB = { ...createNoteWithContent({ title: 'b' }), pinned: true } as jest.Mocked<SNNote>
|
||||
|
||||
expect(sortTwoItems(noteA, noteB, 'title', 'asc')).toEqual(SortRightFirst)
|
||||
expect(sortTwoItems(noteA, noteB, 'title', 'dsc')).toEqual(SortRightFirst)
|
||||
})
|
||||
})
|
||||
83
packages/models/src/Domain/Runtime/Display/SortTwoItems.ts
Normal file
83
packages/models/src/Domain/Runtime/Display/SortTwoItems.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isString } from '@standardnotes/utils'
|
||||
import { CollectionSort, CollectionSortDirection, CollectionSortProperty } from '../Collection/CollectionSort'
|
||||
import { DisplayItem } from './Types'
|
||||
|
||||
export const SortLeftFirst = -1
|
||||
export const SortRightFirst = 1
|
||||
export const KeepSameOrder = 0
|
||||
|
||||
/** @O(n * log(n)) */
|
||||
export function sortTwoItems(
|
||||
a: DisplayItem | undefined,
|
||||
b: DisplayItem | undefined,
|
||||
sortBy: CollectionSortProperty,
|
||||
sortDirection: CollectionSortDirection,
|
||||
bypassPinCheck = false,
|
||||
): number {
|
||||
/** If the elements are undefined, move to beginning */
|
||||
if (!a) {
|
||||
return SortLeftFirst
|
||||
}
|
||||
|
||||
if (!b) {
|
||||
return SortRightFirst
|
||||
}
|
||||
|
||||
if (!bypassPinCheck) {
|
||||
if (a.pinned && b.pinned) {
|
||||
return sortTwoItems(a, b, sortBy, sortDirection, true)
|
||||
}
|
||||
if (a.pinned) {
|
||||
return SortLeftFirst
|
||||
}
|
||||
if (b.pinned) {
|
||||
return SortRightFirst
|
||||
}
|
||||
}
|
||||
|
||||
const aValue = a[sortBy] || ''
|
||||
const bValue = b[sortBy] || ''
|
||||
const smallerNaturallyComesFirst = sortDirection === 'asc'
|
||||
|
||||
let compareResult = KeepSameOrder
|
||||
|
||||
/**
|
||||
* Check for string length due to issue on React Native 0.65.1
|
||||
* where empty strings causes crash:
|
||||
* https://github.com/facebook/react-native/issues/32174
|
||||
* */
|
||||
if (
|
||||
sortBy === CollectionSort.Title &&
|
||||
isString(aValue) &&
|
||||
isString(bValue) &&
|
||||
aValue.length > 0 &&
|
||||
bValue.length > 0
|
||||
) {
|
||||
compareResult = aValue.localeCompare(bValue, 'en', { numeric: true })
|
||||
} else if (aValue > bValue) {
|
||||
compareResult = SortRightFirst
|
||||
} else if (aValue < bValue) {
|
||||
compareResult = SortLeftFirst
|
||||
} else {
|
||||
compareResult = KeepSameOrder
|
||||
}
|
||||
|
||||
const isLeftSmaller = compareResult === SortLeftFirst
|
||||
const isLeftBigger = compareResult === SortRightFirst
|
||||
|
||||
if (isLeftSmaller) {
|
||||
if (smallerNaturallyComesFirst) {
|
||||
return SortLeftFirst
|
||||
} else {
|
||||
return SortRightFirst
|
||||
}
|
||||
} else if (isLeftBigger) {
|
||||
if (smallerNaturallyComesFirst) {
|
||||
return SortRightFirst
|
||||
} else {
|
||||
return SortLeftFirst
|
||||
}
|
||||
} else {
|
||||
return KeepSameOrder
|
||||
}
|
||||
}
|
||||
13
packages/models/src/Domain/Runtime/Display/Types.ts
Normal file
13
packages/models/src/Domain/Runtime/Display/Types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { DecryptedItemInterface } from '../../Abstract/Item'
|
||||
import { SortableItem } from '../Collection/CollectionSort'
|
||||
import { ItemCollection } from '../Collection/Item/ItemCollection'
|
||||
|
||||
export type DisplayControllerCustomFilter = (element: DisplayItem) => boolean
|
||||
export type UuidToSortedPositionMap = Record<Uuid, number>
|
||||
export type DisplayItem = SortableItem & DecryptedItemInterface
|
||||
|
||||
export interface ReadonlyItemCollection {
|
||||
all: ItemCollection['all']
|
||||
has: ItemCollection['has']
|
||||
}
|
||||
8
packages/models/src/Domain/Runtime/Display/index.ts
Normal file
8
packages/models/src/Domain/Runtime/Display/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './DisplayOptions'
|
||||
export * from './DisplayOptionsToFilters'
|
||||
export * from './ItemDisplayController'
|
||||
export * from './Search/ItemWithTags'
|
||||
export * from './Search/SearchableItem'
|
||||
export * from './Search/SearchUtilities'
|
||||
export * from './Search/Types'
|
||||
export * from './Types'
|
||||
Reference in New Issue
Block a user