feat: add models package

This commit is contained in:
Karol Sójko
2022-07-05 20:47:11 +02:00
parent 60d1554ff7
commit b614c71e79
199 changed files with 8772 additions and 22 deletions

View File

@@ -0,0 +1,263 @@
import { extendArray, isObject, isString, UuidMap } from '@standardnotes/utils'
import { ContentType, Uuid } from '@standardnotes/common'
import { remove } from 'lodash'
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { ContentReference } from '../../Abstract/Item'
export interface CollectionElement {
uuid: Uuid
content_type: ContentType
dirty?: boolean
deleted?: boolean
}
export interface DecryptedCollectionElement<C extends ItemContent = ItemContent> extends CollectionElement {
content: C
references: ContentReference[]
}
export interface DeletedCollectionElement extends CollectionElement {
content: undefined
deleted: true
}
export interface EncryptedCollectionElement extends CollectionElement {
content: string
errorDecrypting: boolean
}
export abstract class Collection<
Element extends Decrypted | Encrypted | Deleted,
Decrypted extends DecryptedCollectionElement,
Encrypted extends EncryptedCollectionElement,
Deleted extends DeletedCollectionElement,
> {
readonly map: Partial<Record<Uuid, Element>> = {}
readonly typedMap: Partial<Record<ContentType, Element[]>> = {}
/** An array of uuids of items that are dirty */
dirtyIndex: Set<Uuid> = new Set()
/** An array of uuids of items that are not marked as deleted */
nondeletedIndex: Set<Uuid> = new Set()
/** An array of uuids of items that are errorDecrypting or waitingForKey */
invalidsIndex: Set<Uuid> = new Set()
readonly referenceMap: UuidMap
/** Maintains an index for each item uuid where the value is an array of uuids that are
* conflicts of that item. So if Note B and C are conflicts of Note A,
* conflictMap[A.uuid] == [B.uuid, C.uuid] */
readonly conflictMap: UuidMap
isDecryptedElement = (e: Decrypted | Encrypted | Deleted): e is Decrypted => {
return isObject(e.content)
}
isEncryptedElement = (e: Decrypted | Encrypted | Deleted): e is Encrypted => {
return 'content' in e && isString(e.content)
}
isErrorDecryptingElement = (e: Decrypted | Encrypted | Deleted): e is Encrypted => {
return this.isEncryptedElement(e) && e.errorDecrypting === true
}
isDeletedElement = (e: Decrypted | Encrypted | Deleted): e is Deleted => {
return 'deleted' in e && e.deleted === true
}
isNonDeletedElement = (e: Decrypted | Encrypted | Deleted): e is Decrypted | Encrypted => {
return !this.isDeletedElement(e)
}
constructor(
copy = false,
mapCopy?: Partial<Record<Uuid, Element>>,
typedMapCopy?: Partial<Record<ContentType, Element[]>>,
referenceMapCopy?: UuidMap,
conflictMapCopy?: UuidMap,
) {
if (copy) {
this.map = mapCopy!
this.typedMap = typedMapCopy!
this.referenceMap = referenceMapCopy!
this.conflictMap = conflictMapCopy!
} else {
this.referenceMap = new UuidMap()
this.conflictMap = new UuidMap()
}
}
public uuids(): Uuid[] {
return Object.keys(this.map)
}
public all(contentType?: ContentType | ContentType[]): Element[] {
if (contentType) {
if (Array.isArray(contentType)) {
const elements: Element[] = []
for (const type of contentType) {
extendArray(elements, this.typedMap[type] || [])
}
return elements
} else {
return this.typedMap[contentType]?.slice() || []
}
} else {
return Object.keys(this.map).map((uuid: Uuid) => {
return this.map[uuid]
}) as Element[]
}
}
/** Returns all elements that are not marked as deleted */
public nondeletedElements(): Element[] {
const uuids = Array.from(this.nondeletedIndex)
return this.findAll(uuids).filter(this.isNonDeletedElement)
}
/** Returns all elements that are errorDecrypting or waitingForKey */
public invalidElements(): Encrypted[] {
const uuids = Array.from(this.invalidsIndex)
return this.findAll(uuids) as Encrypted[]
}
/** Returns all elements that are marked as dirty */
public dirtyElements(): Element[] {
const uuids = Array.from(this.dirtyIndex)
return this.findAll(uuids)
}
public findAll(uuids: Uuid[]): Element[] {
const results: Element[] = []
for (const id of uuids) {
const element = this.map[id]
if (element) {
results.push(element)
}
}
return results
}
public find(uuid: Uuid): Element | undefined {
return this.map[uuid]
}
public has(uuid: Uuid): boolean {
return this.find(uuid) != undefined
}
/**
* If an item is not found, an `undefined` element
* will be inserted into the array.
*/
public findAllIncludingBlanks<E extends Element>(uuids: Uuid[]): (E | Deleted | undefined)[] {
const results: (E | Deleted | undefined)[] = []
for (const id of uuids) {
const element = this.map[id] as E | Deleted | undefined
results.push(element)
}
return results
}
public set(elements: Element | Element[]): void {
elements = Array.isArray(elements) ? elements : [elements]
if (elements.length === 0) {
console.warn('Attempting to set 0 elements onto collection')
return
}
for (const element of elements) {
this.map[element.uuid] = element
this.setToTypedMap(element)
if (this.isErrorDecryptingElement(element)) {
this.invalidsIndex.add(element.uuid)
} else {
this.invalidsIndex.delete(element.uuid)
}
if (this.isDecryptedElement(element)) {
const conflictOf = element.content.conflict_of
if (conflictOf) {
this.conflictMap.establishRelationship(conflictOf, element.uuid)
}
this.referenceMap.setAllRelationships(
element.uuid,
element.references.map((r) => r.uuid),
)
}
if (element.dirty) {
this.dirtyIndex.add(element.uuid)
} else {
this.dirtyIndex.delete(element.uuid)
}
if (element.deleted) {
this.nondeletedIndex.delete(element.uuid)
} else {
this.nondeletedIndex.add(element.uuid)
}
}
}
public discard(elements: Element | Element[]): void {
elements = Array.isArray(elements) ? elements : [elements]
for (const element of elements) {
this.deleteFromTypedMap(element)
delete this.map[element.uuid]
this.conflictMap.removeFromMap(element.uuid)
this.referenceMap.removeFromMap(element.uuid)
}
}
public uuidReferencesForUuid(uuid: Uuid): Uuid[] {
return this.referenceMap.getDirectRelationships(uuid)
}
public uuidsThatReferenceUuid(uuid: Uuid): Uuid[] {
return this.referenceMap.getInverseRelationships(uuid)
}
public referencesForElement(element: Decrypted): Element[] {
const uuids = this.referenceMap.getDirectRelationships(element.uuid)
return this.findAll(uuids)
}
public conflictsOf(uuid: Uuid): Element[] {
const uuids = this.conflictMap.getDirectRelationships(uuid)
return this.findAll(uuids)
}
public elementsReferencingElement(element: Decrypted, contentType?: ContentType): Element[] {
const uuids = this.uuidsThatReferenceUuid(element.uuid)
const items = this.findAll(uuids)
if (!contentType) {
return items
}
return items.filter((item) => item.content_type === contentType)
}
private setToTypedMap(element: Element): void {
const array = this.typedMap[element.content_type] || []
remove(array, { uuid: element.uuid as never })
array.push(element)
this.typedMap[element.content_type] = array
}
private deleteFromTypedMap(element: Element): void {
const array = this.typedMap[element.content_type] || []
remove(array, { uuid: element.uuid as never })
this.typedMap[element.content_type] = array
}
}

View File

@@ -0,0 +1,13 @@
import { UuidMap } from '@standardnotes/utils'
export interface CollectionInterface {
/** Maintains an index where the direct map for each item id is an array
* of item ids that the item references. This is essentially equivalent to
* item.content.references, but keeps state even when the item is deleted.
* So if tag A references Note B, referenceMap.directMap[A.uuid] == [B.uuid].
* The inverse map for each item is an array of item ids where the items reference the
* key item. So if tag A references Note B, referenceMap.inverseMap[B.uuid] == [A.uuid].
* This allows callers to determine for a given item, who references it?
* It would be prohibitive to look this up on demand */
readonly referenceMap: UuidMap
}

View File

@@ -0,0 +1,20 @@
import { Uuid, ContentType } from '@standardnotes/common'
export interface SortableItem {
uuid: Uuid
content_type: ContentType
created_at: Date
userModifiedDate: Date
title?: string
pinned: boolean
}
export const CollectionSort: Record<string, keyof SortableItem> = {
CreatedAt: 'created_at',
UpdatedAt: 'userModifiedDate',
Title: 'title',
}
export type CollectionSortDirection = 'asc' | 'dsc'
export type CollectionSortProperty = keyof SortableItem

View File

@@ -0,0 +1,36 @@
import { NoteContent } from './../../../Syncable/Note/NoteContent'
import { ContentType } from '@standardnotes/common'
import { DecryptedItem } from '../../../Abstract/Item'
import { DecryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload'
import { ItemCollection } from './ItemCollection'
import { FillItemContent, ItemContent } from '../../../Abstract/Content/ItemContent'
describe('item collection', () => {
const createDecryptedPayload = (uuid?: string): DecryptedPayload => {
return new DecryptedPayload({
uuid: uuid || String(Math.random()),
content_type: ContentType.Note,
content: FillItemContent<NoteContent>({
title: 'foo',
}),
...PayloadTimestampDefaults(),
})
}
it('setting same item twice should not result in doubles', () => {
const collection = new ItemCollection()
const decryptedItem = new DecryptedItem(createDecryptedPayload())
collection.set(decryptedItem)
const updatedItem = new DecryptedItem(
decryptedItem.payload.copy({
content: { foo: 'bar' } as unknown as jest.Mocked<ItemContent>,
}),
)
collection.set(updatedItem)
expect(collection.all()).toHaveLength(1)
})
})

View File

@@ -0,0 +1,59 @@
import { ItemContent } from './../../../Abstract/Content/ItemContent'
import { EncryptedItemInterface } from './../../../Abstract/Item/Interfaces/EncryptedItem'
import { ContentType, Uuid } from '@standardnotes/common'
import { SNIndex } from '../../Index/SNIndex'
import { isDecryptedItem } from '../../../Abstract/Item/Interfaces/TypeCheck'
import { DecryptedItemInterface } from '../../../Abstract/Item/Interfaces/DecryptedItem'
import { CollectionInterface } from '../CollectionInterface'
import { DeletedItemInterface } from '../../../Abstract/Item'
import { Collection } from '../Collection'
import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes'
import { ItemDelta } from '../../Index/ItemDelta'
export class ItemCollection
extends Collection<AnyItemInterface, DecryptedItemInterface, EncryptedItemInterface, DeletedItemInterface>
implements SNIndex, CollectionInterface
{
public onChange(delta: ItemDelta): void {
const changedOrInserted = delta.changed.concat(delta.inserted)
if (changedOrInserted.length > 0) {
this.set(changedOrInserted)
}
this.discard(delta.discarded)
}
public findDecrypted<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: Uuid): T | undefined {
const result = this.find(uuid)
if (!result) {
return undefined
}
return isDecryptedItem(result) ? (result as T) : undefined
}
public findAllDecrypted<T extends DecryptedItemInterface = DecryptedItemInterface>(uuids: Uuid[]): T[] {
return this.findAll(uuids).filter(isDecryptedItem) as T[]
}
public findAllDecryptedWithBlanks<C extends ItemContent = ItemContent>(
uuids: Uuid[],
): (DecryptedItemInterface<C> | undefined)[] {
const results = this.findAllIncludingBlanks(uuids)
const mapped = results.map((i) => {
if (i == undefined || isDecryptedItem(i)) {
return i
}
return undefined
})
return mapped as (DecryptedItemInterface<C> | undefined)[]
}
public allDecrypted<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[] {
return this.all(contentType).filter(isDecryptedItem) as T[]
}
}

View File

@@ -0,0 +1,65 @@
import { NoteContent } from './../../../Syncable/Note/NoteContent'
import { ContentType } from '@standardnotes/common'
import { DecryptedItem, EncryptedItem } from '../../../Abstract/Item'
import { DecryptedPayload, EncryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload'
import { ItemCollection } from './ItemCollection'
import { FillItemContent } from '../../../Abstract/Content/ItemContent'
import { TagNotesIndex } from './TagNotesIndex'
import { ItemDelta } from '../../Index/ItemDelta'
import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes'
describe('tag notes index', () => {
const createEncryptedItem = (uuid?: string) => {
const payload = new EncryptedPayload({
uuid: uuid || String(Math.random()),
content_type: ContentType.Note,
content: '004:...',
enc_item_key: '004:...',
items_key_id: '123',
waitingForKey: true,
errorDecrypting: true,
...PayloadTimestampDefaults(),
})
return new EncryptedItem(payload)
}
const createDecryptedItem = (uuid?: string) => {
const payload = new DecryptedPayload({
uuid: uuid || String(Math.random()),
content_type: ContentType.Note,
content: FillItemContent<NoteContent>({
title: 'foo',
}),
...PayloadTimestampDefaults(),
})
return new DecryptedItem(payload)
}
const createChangeDelta = (item: AnyItemInterface): ItemDelta => {
return {
changed: [item],
inserted: [],
discarded: [],
ignored: [],
unerrored: [],
}
}
it('should decrement count after decrypted note becomes errored', () => {
const collection = new ItemCollection()
const index = new TagNotesIndex(collection)
const decryptedItem = createDecryptedItem()
collection.set(decryptedItem)
index.onChange(createChangeDelta(decryptedItem))
expect(index.allCountableNotesCount()).toEqual(1)
const encryptedItem = createEncryptedItem(decryptedItem.uuid)
collection.set(encryptedItem)
index.onChange(createChangeDelta(encryptedItem))
expect(index.allCountableNotesCount()).toEqual(0)
})
})

View File

@@ -0,0 +1,111 @@
import { removeFromArray } from '@standardnotes/utils'
import { ContentType, Uuid } from '@standardnotes/common'
import { isTag, SNTag } from '../../../Syncable/Tag/Tag'
import { SNIndex } from '../../Index/SNIndex'
import { ItemCollection } from './ItemCollection'
import { ItemDelta } from '../../Index/ItemDelta'
import { isDecryptedItem, ItemInterface } from '../../../Abstract/Item'
type AllNotesUuidSignifier = undefined
export type TagNoteCountChangeObserver = (tagUuid: Uuid | AllNotesUuidSignifier) => void
export class TagNotesIndex implements SNIndex {
private tagToNotesMap: Partial<Record<Uuid, Set<Uuid>>> = {}
private allCountableNotes = new Set<Uuid>()
constructor(private collection: ItemCollection, public observers: TagNoteCountChangeObserver[] = []) {}
private isNoteCountable = (note: ItemInterface) => {
if (isDecryptedItem(note)) {
return !note.archived && !note.trashed
}
return false
}
public addCountChangeObserver(observer: TagNoteCountChangeObserver): () => void {
this.observers.push(observer)
const thislessEventObservers = this.observers
return () => {
removeFromArray(thislessEventObservers, observer)
}
}
private notifyObservers(tagUuid: Uuid | undefined) {
for (const observer of this.observers) {
observer(tagUuid)
}
}
public allCountableNotesCount(): number {
return this.allCountableNotes.size
}
public countableNotesForTag(tag: SNTag): number {
return this.tagToNotesMap[tag.uuid]?.size || 0
}
public onChange(delta: ItemDelta): void {
const notes = [...delta.changed, ...delta.inserted, ...delta.discarded].filter(
(i) => i.content_type === ContentType.Note,
)
const tags = [...delta.changed, ...delta.inserted].filter(isDecryptedItem).filter(isTag)
this.receiveNoteChanges(notes)
this.receiveTagChanges(tags)
}
private receiveTagChanges(tags: SNTag[]): void {
for (const tag of tags) {
const uuids = tag.noteReferences.map((ref) => ref.uuid)
const countableUuids = uuids.filter((uuid) => this.allCountableNotes.has(uuid))
const previousSet = this.tagToNotesMap[tag.uuid]
this.tagToNotesMap[tag.uuid] = new Set(countableUuids)
if (previousSet?.size !== countableUuids.length) {
this.notifyObservers(tag.uuid)
}
}
}
private receiveNoteChanges(notes: ItemInterface[]): void {
const previousAllCount = this.allCountableNotes.size
for (const note of notes) {
const isCountable = this.isNoteCountable(note)
if (isCountable) {
this.allCountableNotes.add(note.uuid)
} else {
this.allCountableNotes.delete(note.uuid)
}
const associatedTagUuids = this.collection.uuidsThatReferenceUuid(note.uuid)
for (const tagUuid of associatedTagUuids) {
const set = this.setForTag(tagUuid)
const previousCount = set.size
if (isCountable) {
set.add(note.uuid)
} else {
set.delete(note.uuid)
}
if (previousCount !== set.size) {
this.notifyObservers(tagUuid)
}
}
}
if (previousAllCount !== this.allCountableNotes.size) {
this.notifyObservers(undefined)
}
}
private setForTag(uuid: Uuid): Set<Uuid> {
let set = this.tagToNotesMap[uuid]
if (!set) {
set = new Set()
this.tagToNotesMap[uuid] = set
}
return set
}
}

View File

@@ -0,0 +1,54 @@
import { FullyFormedPayloadInterface } from './../../../Abstract/Payload/Interfaces/UnionTypes'
import { ContentType } from '@standardnotes/common'
import { UuidMap } from '@standardnotes/utils'
import { PayloadCollection } from './PayloadCollection'
export class ImmutablePayloadCollection<
P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface,
> extends PayloadCollection<P> {
public get payloads(): P[] {
return this.all()
}
/** We don't use a constructor for this because we don't want the constructor to have
* side-effects, such as calling collection.set(). */
static WithPayloads<T extends FullyFormedPayloadInterface>(payloads: T[] = []): ImmutablePayloadCollection<T> {
const collection = new ImmutablePayloadCollection<T>()
if (payloads.length > 0) {
collection.set(payloads)
}
Object.freeze(collection)
return collection
}
static FromCollection<T extends FullyFormedPayloadInterface>(
collection: PayloadCollection<T>,
): ImmutablePayloadCollection<T> {
const mapCopy = Object.freeze(Object.assign({}, collection.map))
const typedMapCopy = Object.freeze(Object.assign({}, collection.typedMap))
const referenceMapCopy = Object.freeze(collection.referenceMap.makeCopy()) as UuidMap
const conflictMapCopy = Object.freeze(collection.conflictMap.makeCopy()) as UuidMap
const result = new ImmutablePayloadCollection<T>(
true,
mapCopy,
typedMapCopy as Partial<Record<ContentType, T[]>>,
referenceMapCopy,
conflictMapCopy,
)
Object.freeze(result)
return result
}
mutableCopy(): PayloadCollection<P> {
const mapCopy = Object.assign({}, this.map)
const typedMapCopy = Object.assign({}, this.typedMap)
const referenceMapCopy = this.referenceMap.makeCopy()
const conflictMapCopy = this.conflictMap.makeCopy()
const result = new PayloadCollection(true, mapCopy, typedMapCopy, referenceMapCopy, conflictMapCopy)
return result
}
}

View File

@@ -0,0 +1,21 @@
import { FullyFormedPayloadInterface } from './../../../Abstract/Payload/Interfaces/UnionTypes'
import { EncryptedPayloadInterface } from '../../../Abstract/Payload/Interfaces/EncryptedPayload'
import { CollectionInterface } from '../CollectionInterface'
import { DecryptedPayloadInterface } from '../../../Abstract/Payload/Interfaces/DecryptedPayload'
import { IntegrityPayload } from '@standardnotes/responses'
import { Collection } from '../Collection'
import { DeletedPayloadInterface } from '../../../Abstract/Payload'
export class PayloadCollection<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface>
extends Collection<P, DecryptedPayloadInterface, EncryptedPayloadInterface, DeletedPayloadInterface>
implements CollectionInterface
{
public integrityPayloads(): IntegrityPayload[] {
const nondeletedElements = this.nondeletedElements()
return nondeletedElements.map((item) => ({
uuid: item.uuid,
updated_at_timestamp: item.serverUpdatedAtTimestamp as number,
}))
}
}

View File

@@ -0,0 +1,30 @@
import { extendArray } from '@standardnotes/utils'
import { EncryptedPayloadInterface, FullyFormedPayloadInterface, PayloadEmitSource } from '../../../Abstract/Payload'
import { SyncResolvedPayload } from '../Utilities/SyncResolvedPayload'
export type DeltaEmit<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface> = {
emits: P[]
ignored?: EncryptedPayloadInterface[]
source: PayloadEmitSource
}
export type SyncDeltaEmit = {
emits: SyncResolvedPayload[]
ignored?: EncryptedPayloadInterface[]
source: PayloadEmitSource
}
export type SourcelessSyncDeltaEmit = {
emits: SyncResolvedPayload[]
ignored: EncryptedPayloadInterface[]
}
export function extendSyncDelta(base: SyncDeltaEmit, extendWith: SourcelessSyncDeltaEmit): void {
extendArray(base.emits, extendWith.emits)
if (extendWith.ignored) {
if (!base.ignored) {
base.ignored = []
}
extendArray(base.ignored, extendWith.ignored)
}
}

View File

@@ -0,0 +1,24 @@
import { ImmutablePayloadCollection } from '../../Collection/Payload/ImmutablePayloadCollection'
import { DeltaEmit } from './DeltaEmit'
/**
* A payload delta is a class that defines instructions that process an incoming collection
* of payloads, applies some set of operations on those payloads wrt to the current base state,
* and returns the resulting collection. Deltas are purely functional and do not modify
* input data, instead returning what the collection would look like after its been
* transformed. The consumer may choose to act as they wish with this end result.
*
* A delta object takes a baseCollection (the current state of the data) and an applyCollection
* (the data another source is attempting to merge on top of our base data). The delta will
* then iterate over this data and return a `resultingCollection` object that includes the final
* state of the data after the class-specific operations have been applied.
*
* For example, the RemoteRetrieved delta will take the current state of local data as
* baseCollection, the data the server is sending as applyCollection, and determine what
* the end state of the data should look like.
*/
export interface DeltaInterface {
baseCollection: ImmutablePayloadCollection
result(): DeltaEmit
}

View File

@@ -0,0 +1,8 @@
import { ImmutablePayloadCollection } from '../../Collection/Payload/ImmutablePayloadCollection'
import { SyncDeltaEmit } from './DeltaEmit'
export interface SyncDeltaInterface {
baseCollection: ImmutablePayloadCollection
result(): SyncDeltaEmit
}

View File

@@ -0,0 +1,102 @@
import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent'
import { ConflictStrategy } from '../../Abstract/Item'
import {
DecryptedPayload,
EncryptedPayload,
FullyFormedPayloadInterface,
PayloadTimestampDefaults,
} from '../../Abstract/Payload'
import { ItemsKeyContent } from '../../Syncable/ItemsKey/ItemsKeyInterface'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { PayloadCollection } from '../Collection/Payload/PayloadCollection'
import { HistoryMap } from '../History'
import { ConflictDelta } from './Conflict'
describe('conflict delta', () => {
const historyMap = {} as HistoryMap
const createBaseCollection = (payload: FullyFormedPayloadInterface) => {
const baseCollection = new PayloadCollection()
baseCollection.set(payload)
return ImmutablePayloadCollection.FromCollection(baseCollection)
}
const createDecryptedItemsKey = (uuid: string, key: string, timestamp = 0) => {
return new DecryptedPayload<ItemsKeyContent>({
uuid: uuid,
content_type: ContentType.ItemsKey,
content: FillItemContent<ItemsKeyContent>({
itemsKey: key,
}),
...PayloadTimestampDefaults(),
updated_at_timestamp: timestamp,
})
}
const createErroredItemsKey = (uuid: string, timestamp = 0) => {
return new EncryptedPayload({
uuid: uuid,
content_type: ContentType.ItemsKey,
content: '004:...',
enc_item_key: '004:...',
items_key_id: undefined,
errorDecrypting: true,
waitingForKey: false,
...PayloadTimestampDefaults(),
updated_at_timestamp: timestamp,
})
}
it('when apply is an items key, logic should be diverted to items key delta', () => {
const basePayload = createDecryptedItemsKey('123', 'secret')
const baseCollection = createBaseCollection(basePayload)
const applyPayload = createDecryptedItemsKey('123', 'secret', 2)
const delta = new ConflictDelta(baseCollection, basePayload, applyPayload, historyMap)
const mocked = (delta.getConflictStrategy = jest.fn())
delta.result()
expect(mocked).toBeCalledTimes(0)
})
it('if apply payload is errored but base payload is not, should duplicate base and keep apply', () => {
const basePayload = createDecryptedItemsKey('123', 'secret')
const baseCollection = createBaseCollection(basePayload)
const applyPayload = createErroredItemsKey('123', 2)
const delta = new ConflictDelta(baseCollection, basePayload, applyPayload, historyMap)
expect(delta.getConflictStrategy()).toBe(ConflictStrategy.DuplicateBaseKeepApply)
})
it('if base payload is errored but apply is not, should keep base duplicate apply', () => {
const basePayload = createErroredItemsKey('123', 2)
const baseCollection = createBaseCollection(basePayload)
const applyPayload = createDecryptedItemsKey('123', 'secret')
const delta = new ConflictDelta(baseCollection, basePayload, applyPayload, historyMap)
expect(delta.getConflictStrategy()).toBe(ConflictStrategy.KeepBaseDuplicateApply)
})
it('if base and apply are errored, should keep apply', () => {
const basePayload = createErroredItemsKey('123', 2)
const baseCollection = createBaseCollection(basePayload)
const applyPayload = createErroredItemsKey('123', 3)
const delta = new ConflictDelta(baseCollection, basePayload, applyPayload, historyMap)
expect(delta.getConflictStrategy()).toBe(ConflictStrategy.KeepApply)
})
})

View File

@@ -0,0 +1,225 @@
import { greaterOfTwoDates, uniqCombineObjArrays } from '@standardnotes/utils'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { CreateDecryptedItemFromPayload, CreateItemFromPayload } from '../../Utilities/Item/ItemGenerator'
import { HistoryMap, historyMapFunctions } from '../History/HistoryMap'
import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy'
import { PayloadsByDuplicating } from '../../Utilities/Payload/PayloadsByDuplicating'
import { PayloadContentsEqual } from '../../Utilities/Payload/PayloadContentsEqual'
import { FullyFormedPayloadInterface } from '../../Abstract/Payload'
import {
isDecryptedPayload,
isErrorDecryptingPayload,
isDeletedPayload,
} from '../../Abstract/Payload/Interfaces/TypeCheck'
import { ContentType } from '@standardnotes/common'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import { ItemsKeyDelta } from './ItemsKeyDelta'
import { SourcelessSyncDeltaEmit } from './Abstract/DeltaEmit'
import { getIncrementedDirtyIndex } from '../DirtyCounter/DirtyCounter'
export class ConflictDelta {
constructor(
protected readonly baseCollection: ImmutablePayloadCollection,
protected readonly basePayload: FullyFormedPayloadInterface,
protected readonly applyPayload: FullyFormedPayloadInterface,
protected readonly historyMap: HistoryMap,
) {}
public result(): SourcelessSyncDeltaEmit {
if (this.applyPayload.content_type === ContentType.ItemsKey) {
const keyDelta = new ItemsKeyDelta(this.baseCollection, [this.applyPayload])
return keyDelta.result()
}
const strategy = this.getConflictStrategy()
return {
emits: this.handleStrategy(strategy),
ignored: [],
}
}
getConflictStrategy(): ConflictStrategy {
const isBaseErrored = isErrorDecryptingPayload(this.basePayload)
const isApplyErrored = isErrorDecryptingPayload(this.applyPayload)
if (isBaseErrored || isApplyErrored) {
if (isBaseErrored && !isApplyErrored) {
return ConflictStrategy.KeepBaseDuplicateApply
} else if (!isBaseErrored && isApplyErrored) {
return ConflictStrategy.DuplicateBaseKeepApply
} else if (isBaseErrored && isApplyErrored) {
return ConflictStrategy.KeepApply
}
} else if (isDecryptedPayload(this.basePayload)) {
/**
* Ensure no conflict has already been created with the incoming content.
* This can occur in a multi-page sync request where in the middle of the request,
* we make changes to many items, including duplicating, but since we are still not
* uploading the changes until after the multi-page request completes, we may have
* already conflicted this item.
*/
const existingConflict = this.baseCollection.conflictsOf(this.applyPayload.uuid)[0]
if (
existingConflict &&
isDecryptedPayload(existingConflict) &&
isDecryptedPayload(this.applyPayload) &&
PayloadContentsEqual(existingConflict, this.applyPayload)
) {
/** Conflict exists and its contents are the same as incoming value, do not make duplicate */
return ConflictStrategy.KeepBase
} else {
const tmpBaseItem = CreateDecryptedItemFromPayload(this.basePayload)
const tmpApplyItem = CreateItemFromPayload(this.applyPayload)
const historyEntries = this.historyMap[this.basePayload.uuid] || []
const previousRevision = historyMapFunctions.getNewestRevision(historyEntries)
return tmpBaseItem.strategyWhenConflictingWithItem(tmpApplyItem, previousRevision)
}
} else if (isDeletedPayload(this.basePayload) || isDeletedPayload(this.applyPayload)) {
const baseDeleted = isDeletedPayload(this.basePayload)
const applyDeleted = isDeletedPayload(this.applyPayload)
if (baseDeleted && applyDeleted) {
return ConflictStrategy.KeepApply
} else {
return ConflictStrategy.KeepApply
}
}
throw Error('Unhandled strategy in Conflict Delta getConflictStrategy')
}
private handleStrategy(strategy: ConflictStrategy): SyncResolvedPayload[] {
if (strategy === ConflictStrategy.KeepBase) {
return this.handleKeepBaseStrategy()
}
if (strategy === ConflictStrategy.KeepApply) {
return this.handleKeepApplyStrategy()
}
if (strategy === ConflictStrategy.KeepBaseDuplicateApply) {
return this.handleKeepBaseDuplicateApplyStrategy()
}
if (strategy === ConflictStrategy.DuplicateBaseKeepApply) {
return this.handleDuplicateBaseKeepApply()
}
if (strategy === ConflictStrategy.KeepBaseMergeRefs) {
return this.handleKeepBaseMergeRefsStrategy()
}
throw Error('Unhandled strategy in conflict delta payloadsByHandlingStrategy')
}
private handleKeepBaseStrategy(): SyncResolvedPayload[] {
const updatedAt = greaterOfTwoDates(this.basePayload.serverUpdatedAt, this.applyPayload.serverUpdatedAt)
const updatedAtTimestamp = Math.max(this.basePayload.updated_at_timestamp, this.applyPayload.updated_at_timestamp)
const leftPayload = this.basePayload.copyAsSyncResolved(
{
updated_at: updatedAt,
updated_at_timestamp: updatedAtTimestamp,
dirtyIndex: getIncrementedDirtyIndex(),
dirty: true,
lastSyncEnd: new Date(),
},
this.applyPayload.source,
)
return [leftPayload]
}
private handleKeepApplyStrategy(): SyncResolvedPayload[] {
const result = this.applyPayload.copyAsSyncResolved(
{
lastSyncBegan: this.basePayload.lastSyncBegan,
lastSyncEnd: new Date(),
dirty: false,
},
this.applyPayload.source,
)
return [result]
}
private handleKeepBaseDuplicateApplyStrategy(): SyncResolvedPayload[] {
const updatedAt = greaterOfTwoDates(this.basePayload.serverUpdatedAt, this.applyPayload.serverUpdatedAt)
const updatedAtTimestamp = Math.max(this.basePayload.updated_at_timestamp, this.applyPayload.updated_at_timestamp)
const leftPayload = this.basePayload.copyAsSyncResolved(
{
updated_at: updatedAt,
updated_at_timestamp: updatedAtTimestamp,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
lastSyncEnd: new Date(),
},
this.applyPayload.source,
)
const rightPayloads = PayloadsByDuplicating({
payload: this.applyPayload,
baseCollection: this.baseCollection,
isConflict: true,
source: this.applyPayload.source,
})
return [leftPayload].concat(rightPayloads)
}
private handleDuplicateBaseKeepApply(): SyncResolvedPayload[] {
const leftPayloads = PayloadsByDuplicating({
payload: this.basePayload,
baseCollection: this.baseCollection,
isConflict: true,
source: this.applyPayload.source,
})
const rightPayload = this.applyPayload.copyAsSyncResolved(
{
lastSyncBegan: this.basePayload.lastSyncBegan,
dirty: false,
lastSyncEnd: new Date(),
},
this.applyPayload.source,
)
return leftPayloads.concat([rightPayload])
}
private handleKeepBaseMergeRefsStrategy(): SyncResolvedPayload[] {
if (!isDecryptedPayload(this.basePayload) || !isDecryptedPayload(this.applyPayload)) {
return []
}
const refs = uniqCombineObjArrays(this.basePayload.content.references, this.applyPayload.content.references, [
'uuid',
'content_type',
])
const updatedAt = greaterOfTwoDates(this.basePayload.serverUpdatedAt, this.applyPayload.serverUpdatedAt)
const updatedAtTimestamp = Math.max(this.basePayload.updated_at_timestamp, this.applyPayload.updated_at_timestamp)
const payload = this.basePayload.copyAsSyncResolved(
{
updated_at: updatedAt,
updated_at_timestamp: updatedAtTimestamp,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
lastSyncEnd: new Date(),
content: {
...this.basePayload.content,
references: refs,
},
},
this.applyPayload.source,
)
return [payload]
}
}

View File

@@ -0,0 +1,90 @@
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { ConflictDelta } from './Conflict'
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
import { DeletedPayloadInterface, isDecryptedPayload, PayloadEmitSource } from '../../Abstract/Payload'
import { HistoryMap } from '../History'
import { extendSyncDelta, SourcelessSyncDeltaEmit, SyncDeltaEmit } from './Abstract/DeltaEmit'
import { DeltaInterface } from './Abstract/DeltaInterface'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import { getIncrementedDirtyIndex } from '../DirtyCounter/DirtyCounter'
export class DeltaFileImport implements DeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
private readonly applyPayloads: DecryptedPayloadInterface[],
protected readonly historyMap: HistoryMap,
) {}
public result(): SyncDeltaEmit {
const result: SyncDeltaEmit = {
emits: [],
ignored: [],
source: PayloadEmitSource.FileImport,
}
for (const payload of this.applyPayloads) {
const resolved = this.resolvePayload(payload, result)
extendSyncDelta(result, resolved)
}
return result
}
private resolvePayload(
payload: DecryptedPayloadInterface | DeletedPayloadInterface,
currentResults: SyncDeltaEmit,
): SourcelessSyncDeltaEmit {
/**
* Check to see if we've already processed a payload for this id.
* If so, that would be the latest value, and not what's in the base collection.
*/
/*
* Find the most recently created conflict if available, as that
* would contain the most recent value.
*/
let current = currentResults.emits.find((candidate) => {
return isDecryptedPayload(candidate) && candidate.content.conflict_of === payload.uuid
})
/**
* If no latest conflict, find by uuid directly.
*/
if (!current) {
current = currentResults.emits.find((candidate) => {
return candidate.uuid === payload.uuid
})
}
/**
* If not found in current results, use the base value.
*/
if (!current) {
const base = this.baseCollection.find(payload.uuid)
if (base && isDecryptedPayload(base)) {
current = base as SyncResolvedPayload
}
}
/**
* If the current doesn't exist, we're creating a new item from payload.
*/
if (!current) {
return {
emits: [
payload.copyAsSyncResolved({
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
lastSyncEnd: new Date(0),
}),
],
ignored: [],
}
}
const delta = new ConflictDelta(this.baseCollection, current, payload, this.historyMap)
return delta.result()
}
}

View File

@@ -0,0 +1,54 @@
import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent'
import {
DecryptedPayload,
EncryptedPayload,
isEncryptedPayload,
PayloadTimestampDefaults,
} from '../../Abstract/Payload'
import { PayloadCollection } from '../Collection/Payload/PayloadCollection'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { ItemsKeyContent } from '../../Syncable/ItemsKey/ItemsKeyInterface'
import { ItemsKeyDelta } from './ItemsKeyDelta'
describe('items key delta', () => {
it('if local items key is decrypted, incoming encrypted should not overwrite', async () => {
const baseCollection = new PayloadCollection()
const basePayload = new DecryptedPayload<ItemsKeyContent>({
uuid: '123',
content_type: ContentType.ItemsKey,
content: FillItemContent<ItemsKeyContent>({
itemsKey: 'secret',
}),
...PayloadTimestampDefaults(),
updated_at_timestamp: 1,
})
baseCollection.set(basePayload)
const payloadToIgnore = new EncryptedPayload({
uuid: '123',
content_type: ContentType.ItemsKey,
content: '004:...',
enc_item_key: '004:...',
items_key_id: undefined,
errorDecrypting: false,
waitingForKey: false,
...PayloadTimestampDefaults(),
updated_at_timestamp: 2,
})
const delta = new ItemsKeyDelta(ImmutablePayloadCollection.FromCollection(baseCollection), [payloadToIgnore])
const result = delta.result()
const updatedBasePayload = result.emits?.[0] as DecryptedPayload<ItemsKeyContent>
expect(updatedBasePayload.content.itemsKey).toBe('secret')
expect(updatedBasePayload.updated_at_timestamp).toBe(2)
expect(updatedBasePayload.dirty).toBeFalsy()
const ignored = result.ignored?.[0] as EncryptedPayload
expect(ignored).toBeTruthy()
expect(isEncryptedPayload(ignored)).toBe(true)
})
})

View File

@@ -0,0 +1,52 @@
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import {
EncryptedPayloadInterface,
FullyFormedPayloadInterface,
isDecryptedPayload,
isEncryptedPayload,
} from '../../Abstract/Payload'
import { SourcelessSyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
export class ItemsKeyDelta {
constructor(
private baseCollection: ImmutablePayloadCollection,
private readonly applyPayloads: FullyFormedPayloadInterface[],
) {}
public result(): SourcelessSyncDeltaEmit {
const emits: SyncResolvedPayload[] = []
const ignored: EncryptedPayloadInterface[] = []
for (const apply of this.applyPayloads) {
const base = this.baseCollection.find(apply.uuid)
if (!base) {
emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
continue
}
if (isEncryptedPayload(apply) && isDecryptedPayload(base)) {
const keepBaseWithApplyTimestamps = base.copyAsSyncResolved({
updated_at_timestamp: apply.updated_at_timestamp,
updated_at: apply.updated_at,
dirty: false,
lastSyncEnd: new Date(),
})
emits.push(keepBaseWithApplyTimestamps)
ignored.push(apply)
} else {
emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
}
}
return {
emits: emits,
ignored,
}
}
}

View File

@@ -0,0 +1,33 @@
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload'
import { OfflineSyncSavedContextualPayload } from '../../Abstract/Contextual/OfflineSyncSaved'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
import { SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
export class DeltaOfflineSaved implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection<FullyFormedPayloadInterface>,
readonly applyContextualPayloads: OfflineSyncSavedContextualPayload[],
) {}
public result(): SyncDeltaEmit {
const processed: SyncResolvedPayload[] = []
for (const apply of this.applyContextualPayloads) {
const base = this.baseCollection.find(apply.uuid)
if (!base) {
continue
}
processed.push(payloadByFinalizingSyncState(base, this.baseCollection))
}
return {
emits: processed,
source: PayloadEmitSource.OfflineSyncSaved,
}
}
}

View File

@@ -0,0 +1,62 @@
import { PayloadEmitSource } from '../../Abstract/Payload'
import { isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck'
import { PayloadContentsEqual } from '../../Utilities/Payload/PayloadContentsEqual'
import { ConflictDelta } from './Conflict'
import { ContentType } from '@standardnotes/common'
import { ItemsKeyDelta } from './ItemsKeyDelta'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { HistoryMap } from '../History'
import { extendSyncDelta, SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
export class DeltaOutOfSync implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
readonly historyMap: HistoryMap,
) {}
public result(): SyncDeltaEmit {
const result: SyncDeltaEmit = {
emits: [],
ignored: [],
source: PayloadEmitSource.RemoteRetrieved,
}
for (const apply of this.applyCollection.all()) {
if (apply.content_type === ContentType.ItemsKey) {
const itemsKeyDeltaEmit = new ItemsKeyDelta(this.baseCollection, [apply]).result()
extendSyncDelta(result, itemsKeyDeltaEmit)
continue
}
const base = this.baseCollection.find(apply.uuid)
if (!base) {
result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
continue
}
const isBaseDecrypted = isDecryptedPayload(base)
const isApplyDecrypted = isDecryptedPayload(apply)
const needsConflict =
isApplyDecrypted !== isBaseDecrypted ||
(isApplyDecrypted && isBaseDecrypted && !PayloadContentsEqual(apply, base))
if (needsConflict) {
const delta = new ConflictDelta(this.baseCollection, base, apply, this.historyMap)
extendSyncDelta(result, delta.result())
} else {
result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
}
}
return result
}
}

View File

@@ -0,0 +1,41 @@
import { ConflictDelta } from './Conflict'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { HistoryMap } from '../History'
import { extendSyncDelta, SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
export class DeltaRemoteDataConflicts implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
readonly historyMap: HistoryMap,
) {}
public result(): SyncDeltaEmit {
const result: SyncDeltaEmit = {
emits: [],
ignored: [],
source: PayloadEmitSource.RemoteRetrieved,
}
for (const apply of this.applyCollection.all()) {
const base = this.baseCollection.find(apply.uuid)
const isBaseDeleted = base == undefined
if (isBaseDeleted) {
result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
continue
}
const delta = new ConflictDelta(this.baseCollection, base, apply, this.historyMap)
extendSyncDelta(result, delta.result())
}
return result
}
}

View File

@@ -0,0 +1,45 @@
import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent'
import { DecryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload'
import { NoteContent } from '../../Syncable/Note'
import { PayloadCollection } from '../Collection/Payload/PayloadCollection'
import { DeltaRemoteRejected } from './RemoteRejected'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
describe('remote rejected delta', () => {
it('rejected payloads should not map onto app state', async () => {
const baseCollection = new PayloadCollection()
const basePayload = new DecryptedPayload<NoteContent>({
uuid: '123',
content_type: ContentType.Note,
dirty: true,
content: FillItemContent<NoteContent>({
title: 'foo',
}),
...PayloadTimestampDefaults(),
updated_at_timestamp: 1,
})
baseCollection.set(basePayload)
const rejectedPayload = basePayload.copy({
content: FillItemContent<NoteContent>({
title: 'rejected',
}),
updated_at_timestamp: 3,
dirty: true,
})
const delta = new DeltaRemoteRejected(
ImmutablePayloadCollection.FromCollection(baseCollection),
ImmutablePayloadCollection.WithPayloads([rejectedPayload]),
)
const result = delta.result()
const payload = result.emits[0] as DecryptedPayload<NoteContent>
expect(payload.content.title).toBe('foo')
expect(payload.updated_at_timestamp).toBe(1)
expect(payload.dirty).toBeFalsy()
})
})

View File

@@ -0,0 +1,40 @@
import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
export class DeltaRemoteRejected implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
) {}
public result(): SyncDeltaEmit {
const results: SyncResolvedPayload[] = []
for (const apply of this.applyCollection.all()) {
const base = this.baseCollection.find(apply.uuid)
if (!base) {
continue
}
const result = base.copyAsSyncResolved(
{
dirty: false,
lastSyncEnd: new Date(),
},
PayloadSource.RemoteSaved,
)
results.push(result)
}
return {
emits: results,
source: PayloadEmitSource.RemoteSaved,
}
}
}

View File

@@ -0,0 +1,60 @@
import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent'
import {
DecryptedPayload,
EncryptedPayload,
isEncryptedPayload,
PayloadTimestampDefaults,
} from '../../Abstract/Payload'
import { PayloadCollection } from '../Collection/Payload/PayloadCollection'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { ItemsKeyContent } from '../../Syncable/ItemsKey/ItemsKeyInterface'
import { DeltaRemoteRetrieved } from './RemoteRetrieved'
describe('remote retrieved delta', () => {
it('if local items key is decrypted, incoming encrypted should not overwrite', async () => {
const baseCollection = new PayloadCollection()
const basePayload = new DecryptedPayload<ItemsKeyContent>({
uuid: '123',
content_type: ContentType.ItemsKey,
content: FillItemContent<ItemsKeyContent>({
itemsKey: 'secret',
}),
...PayloadTimestampDefaults(),
updated_at_timestamp: 1,
})
baseCollection.set(basePayload)
const payloadToIgnore = new EncryptedPayload({
uuid: '123',
content_type: ContentType.ItemsKey,
content: '004:...',
enc_item_key: '004:...',
items_key_id: undefined,
errorDecrypting: false,
waitingForKey: false,
...PayloadTimestampDefaults(),
updated_at_timestamp: 2,
})
const delta = new DeltaRemoteRetrieved(
ImmutablePayloadCollection.FromCollection(baseCollection),
ImmutablePayloadCollection.WithPayloads([payloadToIgnore]),
[],
{},
)
const result = delta.result()
const updatedBasePayload = result.emits?.[0] as DecryptedPayload<ItemsKeyContent>
expect(updatedBasePayload.content.itemsKey).toBe('secret')
expect(updatedBasePayload.updated_at_timestamp).toBe(2)
expect(updatedBasePayload.dirty).toBeFalsy()
const ignored = result.ignored?.[0] as EncryptedPayload
expect(ignored).toBeTruthy()
expect(isEncryptedPayload(ignored)).toBe(true)
})
})

View File

@@ -0,0 +1,87 @@
import { ImmutablePayloadCollection } from './../Collection/Payload/ImmutablePayloadCollection'
import { ConflictDelta } from './Conflict'
import { isErrorDecryptingPayload, isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck'
import { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload'
import { ContentType, Uuid } from '@standardnotes/common'
import { HistoryMap } from '../History'
import { ServerSyncPushContextualPayload } from '../../Abstract/Contextual/ServerSyncPush'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
import { ItemsKeyDelta } from './ItemsKeyDelta'
import { extendSyncDelta, SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
export class DeltaRemoteRetrieved implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
private itemsSavedOrSaving: ServerSyncPushContextualPayload[],
readonly historyMap: HistoryMap,
) {}
private isUuidOfPayloadCurrentlySavingOrSaved(uuid: Uuid): boolean {
return this.itemsSavedOrSaving.find((i) => i.uuid === uuid) != undefined
}
public result(): SyncDeltaEmit {
const result: SyncDeltaEmit = {
emits: [],
ignored: [],
source: PayloadEmitSource.RemoteRetrieved,
}
const conflicted: FullyFormedPayloadInterface[] = []
/**
* If we have retrieved an item that was saved as part of this ongoing sync operation,
* or if the item is locally dirty, filter it out of retrieved_items, and add to potential conflicts.
*/
for (const apply of this.applyCollection.all()) {
if (apply.content_type === ContentType.ItemsKey) {
const itemsKeyDeltaEmit = new ItemsKeyDelta(this.baseCollection, [apply]).result()
extendSyncDelta(result, itemsKeyDeltaEmit)
continue
}
const isSavedOrSaving = this.isUuidOfPayloadCurrentlySavingOrSaved(apply.uuid)
if (isSavedOrSaving) {
conflicted.push(apply)
continue
}
const base = this.baseCollection.find(apply.uuid)
if (base?.dirty && !isErrorDecryptingPayload(base)) {
conflicted.push(apply)
continue
}
result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
}
/**
* For any potential conflict above, we compare the values with current
* local values, and if they differ, we create a new payload that is a copy
* of the server payload.
*/
for (const conflict of conflicted) {
if (!isDecryptedPayload(conflict)) {
continue
}
const base = this.baseCollection.find(conflict.uuid)
if (!base) {
continue
}
const delta = new ConflictDelta(this.baseCollection, base, conflict, this.historyMap)
extendSyncDelta(result, delta.result())
}
return result
}
}

View File

@@ -0,0 +1,99 @@
import { ServerSyncSavedContextualPayload } from './../../Abstract/Contextual/ServerSyncSaved'
import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource'
import { isDeletedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState'
import { SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { BuildSyncResolvedParams, SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import { getIncrementedDirtyIndex } from '../DirtyCounter/DirtyCounter'
export class DeltaRemoteSaved implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
private readonly applyContextualPayloads: ServerSyncSavedContextualPayload[],
) {}
public result(): SyncDeltaEmit {
const processed: SyncResolvedPayload[] = []
for (const apply of this.applyContextualPayloads) {
const base = this.baseCollection.find(apply.uuid)
if (!base) {
const discarded = new DeletedPayload(
{
...apply,
deleted: true,
content: undefined,
...BuildSyncResolvedParams({
dirty: false,
lastSyncEnd: new Date(),
}),
},
PayloadSource.RemoteSaved,
)
processed.push(discarded as SyncResolvedPayload)
continue
}
/**
* If we save an item, but while in transit it is deleted locally, we want to keep
* local deletion status, and not old (false) deleted value that was sent to server.
*/
if (isDeletedPayload(base)) {
const baseWasDeletedAfterThisRequest = !apply.deleted
const regularDeletedPayload = apply.deleted
if (baseWasDeletedAfterThisRequest) {
const result = new DeletedPayload(
{
...apply,
deleted: true,
content: undefined,
dirtyIndex: getIncrementedDirtyIndex(),
...BuildSyncResolvedParams({
dirty: true,
lastSyncEnd: new Date(),
}),
},
PayloadSource.RemoteSaved,
)
processed.push(result as SyncResolvedPayload)
} else if (regularDeletedPayload) {
const discarded = base.copy(
{
...apply,
deleted: true,
...BuildSyncResolvedParams({
dirty: false,
lastSyncEnd: new Date(),
}),
},
PayloadSource.RemoteSaved,
)
processed.push(discarded as SyncResolvedPayload)
}
} else {
const result = payloadByFinalizingSyncState(
base.copy(
{
...apply,
deleted: false,
},
PayloadSource.RemoteSaved,
),
this.baseCollection,
)
processed.push(result)
}
}
return {
emits: processed,
source: PayloadEmitSource.RemoteSaved,
}
}
}

View File

@@ -0,0 +1,56 @@
import { extendArray, filterFromArray, Uuids } from '@standardnotes/utils'
import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection'
import { PayloadsByAlternatingUuid } from '../../Utilities/Payload/PayloadsByAlternatingUuid'
import { isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
/**
* UUID conflicts can occur if a user attmpts to import an old data
* backup with uuids from the old account into a new account.
* In uuid_conflict, we receive the value we attmpted to save.
*/
export class DeltaRemoteUuidConflicts implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
) {}
public result(): SyncDeltaEmit {
const results: SyncResolvedPayload[] = []
const baseCollectionCopy = this.baseCollection.mutableCopy()
for (const apply of this.applyCollection.all()) {
/**
* The payload in question may have been modified as part of alternating a uuid for
* another item. For example, alternating a uuid for a note will also affect the
* referencing tag, which would be added to `results`, but could also be inside
* of this.applyCollection. In this case we'd prefer the most recently modified value.
*/
const moreRecent = results.find((r) => r.uuid === apply.uuid)
const useApply = moreRecent || apply
if (!isDecryptedPayload(useApply)) {
continue
}
const alternateResults = PayloadsByAlternatingUuid(
useApply,
ImmutablePayloadCollection.FromCollection(baseCollectionCopy),
)
baseCollectionCopy.set(alternateResults)
filterFromArray(results, (r) => Uuids(alternateResults).includes(r.uuid))
extendArray(results, alternateResults)
}
return {
emits: results,
source: PayloadEmitSource.RemoteRetrieved,
}
}
}

View File

@@ -0,0 +1,36 @@
import { ImmutablePayloadCollection } from '../../Collection/Payload/ImmutablePayloadCollection'
import { FullyFormedPayloadInterface } from '../../../Abstract/Payload/Interfaces/UnionTypes'
import { SyncResolvedPayload } from './SyncResolvedPayload'
import { getIncrementedDirtyIndex } from '../../DirtyCounter/DirtyCounter'
export function payloadByFinalizingSyncState(
payload: FullyFormedPayloadInterface,
baseCollection: ImmutablePayloadCollection,
): SyncResolvedPayload {
const basePayload = baseCollection.find(payload.uuid)
if (!basePayload) {
return payload.copyAsSyncResolved({
dirty: false,
lastSyncEnd: new Date(),
})
}
const stillDirty =
basePayload.dirtyIndex && basePayload.globalDirtyIndexAtLastSync
? basePayload.dirtyIndex > basePayload.globalDirtyIndexAtLastSync
: false
return payload.copyAsSyncResolved({
dirty: stillDirty,
dirtyIndex: stillDirty ? getIncrementedDirtyIndex() : undefined,
lastSyncEnd: new Date(),
})
}
export function payloadsByFinalizingSyncState(
payloads: FullyFormedPayloadInterface[],
baseCollection: ImmutablePayloadCollection,
): SyncResolvedPayload[] {
return payloads.map((p) => payloadByFinalizingSyncState(p, baseCollection))
}

View File

@@ -0,0 +1,12 @@
import { FullyFormedPayloadInterface } from '../../../Abstract/Payload'
export interface SyncResolvedParams {
dirty: boolean
lastSyncEnd: Date
}
export function BuildSyncResolvedParams(params: SyncResolvedParams): SyncResolvedParams {
return params
}
export type SyncResolvedPayload = SyncResolvedParams & FullyFormedPayloadInterface

View File

@@ -0,0 +1,10 @@
export * from './Conflict'
export * from './FileImport'
export * from './OutOfSync'
export * from './RemoteDataConflicts'
export * from './RemoteRetrieved'
export * from './RemoteSaved'
export * from './OfflineSaved'
export * from './RemoteUuidConflicts'
export * from './RemoteRejected'
export * from './Abstract/DeltaEmit'

View File

@@ -0,0 +1,10 @@
let dirtyIndex = 0
export function getIncrementedDirtyIndex() {
dirtyIndex++
return dirtyIndex
}
export function getCurrentDirtyIndex() {
return dirtyIndex
}

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export interface SearchableItem {
uuid: string
title?: string
text?: string
}

View 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

View File

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

View 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
}
}

View 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']
}

View 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'

View File

@@ -0,0 +1,24 @@
import { DecryptedPayloadInterface } from './../../Abstract/Payload/Interfaces/DecryptedPayload'
import { ContentType } from '@standardnotes/common'
import { NoteContent } from '../../Syncable/Note'
import { HistoryEntry } from './HistoryEntry'
import { NoteHistoryEntry } from './NoteHistoryEntry'
export function CreateHistoryEntryForPayload(
payload: DecryptedPayloadInterface<NoteContent>,
previousEntry?: HistoryEntry,
): HistoryEntry {
const type = payload.content_type
const historyItemClass = historyClassForContentType(type)
const entry = new historyItemClass(payload, previousEntry)
return entry
}
function historyClassForContentType(contentType: ContentType) {
switch (contentType) {
case ContentType.Note:
return NoteHistoryEntry
default:
return HistoryEntry
}
}

View File

@@ -0,0 +1,98 @@
import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem'
import { DecryptedPayloadInterface } from './../../Abstract/Payload/Interfaces/DecryptedPayload'
import { isNullOrUndefined } from '@standardnotes/utils'
import { CreateDecryptedItemFromPayload } from '../../Utilities/Item/ItemGenerator'
import { NoteContent } from '../../Syncable/Note'
import { HistoryEntryInterface } from './HistoryEntryInterface'
export class HistoryEntry implements HistoryEntryInterface {
public readonly payload: DecryptedPayloadInterface<NoteContent>
public readonly previousEntry?: HistoryEntry
protected readonly defaultContentKeyToDiffOn: keyof NoteContent = 'text'
protected readonly textCharDiffLength: number
protected readonly hasPreviousEntry: boolean
constructor(payload: DecryptedPayloadInterface<NoteContent>, previousEntry?: HistoryEntry) {
this.payload = payload.copy()
this.previousEntry = previousEntry
this.hasPreviousEntry = !isNullOrUndefined(previousEntry)
/** We'll try to compute the delta based on an assumed
* content property of `text`, if it exists. */
const propertyValue = this.payload.content[this.defaultContentKeyToDiffOn] as string | undefined
if (propertyValue) {
if (previousEntry) {
const previousValue = (previousEntry.payload.content[this.defaultContentKeyToDiffOn] as string)?.length || 0
this.textCharDiffLength = propertyValue.length - previousValue
} else {
this.textCharDiffLength = propertyValue.length
}
} else {
this.textCharDiffLength = 0
}
}
public itemFromPayload(): DecryptedItemInterface {
return CreateDecryptedItemFromPayload(this.payload)
}
public isSameAsEntry(entry: HistoryEntry): boolean {
if (!entry) {
return false
}
const lhs = this.itemFromPayload()
const rhs = entry.itemFromPayload()
const datesEqual = lhs.userModifiedDate.getTime() === rhs.userModifiedDate.getTime()
if (!datesEqual) {
return false
}
/** Dates are the same, but because JS is only accurate to milliseconds,
* items can have different content but same dates */
return lhs.isItemContentEqualWith(rhs)
}
public isDiscardable(): boolean {
return false
}
public operationVector(): number {
/**
* We'll try to use the value of `textCharDiffLength`
* to help determine this, if it's set
*/
if (this.textCharDiffLength !== undefined) {
if (!this.hasPreviousEntry || this.textCharDiffLength === 0) {
return 0
} else if (this.textCharDiffLength < 0) {
return -1
} else {
return 1
}
}
/** Otherwise use a default value of 1 */
return 1
}
public deltaSize(): number {
/**
* Up to the subclass to determine how large the delta was,
* i.e number of characters changed.
* But this general class won't be able to determine which property it
* should diff on, or even its format.
*/
/**
* We can return the `textCharDiffLength` if it's set,
* otherwise, just return 1;
*/
if (this.textCharDiffLength !== undefined) {
return Math.abs(this.textCharDiffLength)
}
/**
* Otherwise return 1 here to constitute a basic positive delta.
* The value returned should always be positive. Override `operationVector`
* to return the direction of the delta.
*/
return 1
}
}

View File

@@ -0,0 +1,13 @@
import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem'
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
import { NoteContent } from '../../Syncable/Note/NoteContent'
export interface HistoryEntryInterface {
readonly payload: DecryptedPayloadInterface<NoteContent>
readonly previousEntry?: HistoryEntryInterface
itemFromPayload(): DecryptedItemInterface
isSameAsEntry(entry: HistoryEntryInterface): boolean
isDiscardable(): boolean
operationVector(): number
deltaSize(): number
}

View File

@@ -0,0 +1,10 @@
import { Uuid } from '@standardnotes/common'
import { HistoryEntry } from './HistoryEntry'
export type HistoryMap = Record<Uuid, HistoryEntry[]>
export const historyMapFunctions = {
getNewestRevision: (history: HistoryEntry[]): HistoryEntry | undefined => {
return history[0]
},
}

View File

@@ -0,0 +1,28 @@
import { isEmpty } from '@standardnotes/utils'
import { HistoryEntry } from './HistoryEntry'
export class NoteHistoryEntry extends HistoryEntry {
previewTitle(): string {
if (this.payload.updated_at.getTime() > 0) {
return this.payload.updated_at.toLocaleString()
} else {
return this.payload.created_at.toLocaleString()
}
}
previewSubTitle(): string {
if (!this.hasPreviousEntry) {
return `${this.textCharDiffLength} characters loaded`
} else if (this.textCharDiffLength < 0) {
return `${this.textCharDiffLength * -1} characters removed`
} else if (this.textCharDiffLength > 0) {
return `${this.textCharDiffLength} characters added`
} else {
return 'Title or metadata changed'
}
}
public override isDiscardable(): boolean {
return isEmpty(this.payload.content.text)
}
}

View File

@@ -0,0 +1,5 @@
export * from './Generator'
export * from './HistoryEntry'
export * from './HistoryMap'
export * from './NoteHistoryEntry'
export * from './HistoryEntryInterface'

View File

@@ -0,0 +1,24 @@
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { DecryptedItemInterface, DeletedItemInterface, EncryptedItemInterface } from '../../Abstract/Item'
import { AnyItemInterface } from '../../Abstract/Item/Interfaces/UnionTypes'
export interface ItemDelta<C extends ItemContent = ItemContent> {
changed: AnyItemInterface[]
inserted: AnyItemInterface[]
/** Items that were deleted and finished sync */
discarded: DeletedItemInterface[]
/** Items which have encrypted overwrite protection enabled */
ignored: EncryptedItemInterface[]
/** Items which were previously error decrypting which have now been successfully decrypted */
unerrored: DecryptedItemInterface<C>[]
}
export function CreateItemDelta(partial: Partial<ItemDelta>): ItemDelta {
return {
changed: partial.changed || [],
inserted: partial.inserted || [],
discarded: partial.discarded || [],
ignored: partial.ignored || [],
unerrored: partial.unerrored || [],
}
}

View File

@@ -0,0 +1,5 @@
import { ItemDelta } from './ItemDelta'
export interface SNIndex {
onChange(delta: ItemDelta): void
}

View File

@@ -0,0 +1,46 @@
import { PredicateTarget, PredicateCompoundOperator, PredicateInterface, PredicateJsonForm } from './Interface'
export class CompoundPredicate<T extends PredicateTarget> implements PredicateInterface<T> {
constructor(
public readonly operator: PredicateCompoundOperator,
public readonly predicates: PredicateInterface<T>[],
) {}
matchesItem(item: T): boolean {
if (this.operator === 'and') {
for (const subPredicate of this.predicates) {
if (!subPredicate.matchesItem(item)) {
return false
}
}
return true
}
if (this.operator === 'or') {
for (const subPredicate of this.predicates) {
if (subPredicate.matchesItem(item)) {
return true
}
}
return false
}
return false
}
keypathIncludesString(verb: string): boolean {
for (const subPredicate of this.predicates) {
if (subPredicate.keypathIncludesString(verb)) {
return true
}
}
return false
}
toJson(): PredicateJsonForm {
return {
operator: this.operator,
value: this.predicates.map((predicate) => predicate.toJson()),
}
}
}

View File

@@ -0,0 +1,140 @@
import { CompoundPredicate } from './CompoundPredicate'
import { IncludesPredicate } from './IncludesPredicate'
import {
AllPredicateCompoundOperators,
PredicateCompoundOperator,
PredicateInterface,
PredicateOperator,
SureValue,
PredicateJsonForm,
AllPredicateOperators,
RawPredicateInArrayForm,
SureValueNonObjectTypesAsStrings,
StringKey,
PredicateTarget,
} from './Interface'
import { NotPredicate } from './NotPredicate'
import { Predicate } from './Predicate'
export function predicateFromArguments<T extends PredicateTarget>(
keypath: StringKey<T> | undefined,
operator: PredicateOperator,
value: SureValue | PredicateJsonForm,
): PredicateInterface<T> {
if (AllPredicateCompoundOperators.includes(operator as PredicateCompoundOperator)) {
return compoundPredicateFromArguments(operator, value as unknown as PredicateJsonForm[])
} else if (operator === 'not') {
return new NotPredicate(predicateFromJson(value as PredicateJsonForm))
} else if (operator === 'includes' && keypath) {
if (isSureValue(value)) {
return new Predicate(keypath, operator, value)
} else {
return new IncludesPredicate(keypath, predicateFromJson(value as PredicateJsonForm))
}
} else if (keypath) {
return new Predicate(keypath, operator, value as SureValue)
}
throw Error('Invalid predicate arguments')
}
export function compoundPredicateFromArguments<T extends PredicateTarget>(
operator: PredicateOperator,
value: PredicateJsonForm[],
): PredicateInterface<T> {
const subPredicates = value.map((jsonPredicate) => {
return predicateFromJson(jsonPredicate)
})
return new CompoundPredicate(operator as PredicateCompoundOperator, subPredicates)
}
export function notPredicateFromArguments<T extends PredicateTarget>(value: PredicateJsonForm): PredicateInterface<T> {
const subPredicate = predicateFromJson(value)
return new NotPredicate(subPredicate)
}
export function includesPredicateFromArguments<T extends PredicateTarget>(
keypath: StringKey<T>,
value: PredicateJsonForm,
): PredicateInterface<T> {
const subPredicate = predicateFromJson(value)
return new IncludesPredicate<T>(keypath, subPredicate)
}
export function predicateFromJson<T extends PredicateTarget>(values: PredicateJsonForm): PredicateInterface<T> {
if (Array.isArray(values)) {
throw Error('Invalid predicateFromJson value')
}
return predicateFromArguments(
values.keypath as StringKey<T>,
values.operator,
isValuePredicateInArrayForm(values.value)
? predicateDSLArrayToJsonPredicate(values.value)
: (values.value as PredicateJsonForm),
)
}
export function predicateFromDSLString<T extends PredicateTarget>(dsl: string): PredicateInterface<T> {
try {
const components = JSON.parse(dsl.substring(1, dsl.length)) as string[]
components.shift()
const predicateJson = predicateDSLArrayToJsonPredicate(components as RawPredicateInArrayForm)
return predicateFromJson(predicateJson)
} catch (e) {
throw Error(`Invalid smart view syntax ${e}`)
}
}
function isValuePredicateInArrayForm(
value: SureValue | PredicateJsonForm | PredicateJsonForm[] | RawPredicateInArrayForm,
): value is RawPredicateInArrayForm {
return Array.isArray(value) && AllPredicateOperators.includes(value[1] as PredicateOperator)
}
function isSureValue(value: unknown): value is SureValue {
if (SureValueNonObjectTypesAsStrings.includes(typeof value)) {
return true
}
if (Array.isArray(value)) {
return !isValuePredicateInArrayForm(value)
}
return false
}
function predicateDSLArrayToJsonPredicate(predicateArray: RawPredicateInArrayForm): PredicateJsonForm {
const predicateValue = predicateArray[2] as
| SureValue
| SureValue[]
| RawPredicateInArrayForm
| RawPredicateInArrayForm[]
let resolvedPredicateValue: PredicateJsonForm | SureValue | PredicateJsonForm[]
if (Array.isArray(predicateValue)) {
const level1CondensedValue = predicateValue as SureValue[] | RawPredicateInArrayForm | RawPredicateInArrayForm[]
if (Array.isArray(level1CondensedValue[0])) {
const level2CondensedValue = level1CondensedValue as RawPredicateInArrayForm[]
resolvedPredicateValue = level2CondensedValue.map((subPredicate) =>
predicateDSLArrayToJsonPredicate(subPredicate),
)
} else if (isValuePredicateInArrayForm(predicateValue[1])) {
const level2CondensedValue = level1CondensedValue as RawPredicateInArrayForm
resolvedPredicateValue = predicateDSLArrayToJsonPredicate(level2CondensedValue)
} else {
const level2CondensedValue = predicateValue as SureValue
resolvedPredicateValue = level2CondensedValue
}
} else {
const level1CondensedValue = predicateValue as SureValue
resolvedPredicateValue = level1CondensedValue
}
return {
keypath: predicateArray[0],
operator: predicateArray[1] as PredicateOperator,
value: resolvedPredicateValue,
}
}

View File

@@ -0,0 +1,32 @@
import { PredicateTarget, PredicateInterface, PredicateJsonForm, StringKey } from './Interface'
export class IncludesPredicate<T extends PredicateTarget> implements PredicateInterface<T> {
constructor(private readonly keypath: StringKey<T>, public readonly predicate: PredicateInterface<T>) {}
matchesItem(item: T): boolean {
const keyPathComponents = this.keypath.split('.') as StringKey<T>[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valueAtKeyPath: T = keyPathComponents.reduce<any>((previous, current) => {
return previous && previous[current]
}, item)
if (!Array.isArray(valueAtKeyPath)) {
return false
}
return valueAtKeyPath.some((subItem) => this.predicate.matchesItem(subItem))
}
keypathIncludesString(verb: string): boolean {
return this.keypath.includes(verb)
}
toJson(): PredicateJsonForm {
return {
keypath: this.keypath,
operator: 'includes',
value: this.predicate.toJson(),
}
}
}

View File

@@ -0,0 +1,45 @@
export interface PredicateInterface<T> {
matchesItem(item: T): boolean
keypathIncludesString(verb: string): boolean
toJson(): PredicateJsonForm
}
export type RawPredicateInArrayForm = string[]
export interface PredicateJsonForm {
keypath?: string
operator: PredicateOperator
value: SureValue | PredicateJsonForm | PredicateJsonForm[] | RawPredicateInArrayForm
}
export const AllPredicateCompoundOperators = ['and', 'or'] as const
export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number]
export const AllPredicateOperators = [
...AllPredicateCompoundOperators,
'!=',
'=',
'<',
'>',
'<=',
'>=',
'startsWith',
'in',
'matches',
'not',
'includes',
] as const
export type PredicateOperator = typeof AllPredicateOperators[number]
export type SureValue = number | number[] | string[] | string | Date | boolean | false | ''
export const SureValueNonObjectTypesAsStrings = ['number', 'string', 'boolean']
export type FalseyValue = false | '' | null | undefined
export type PrimitiveOperand = SureValue | FalseyValue
export type PredicateTarget = unknown
export type StringKey<T = unknown> = keyof T & string

View File

@@ -0,0 +1,20 @@
import { PredicateTarget, PredicateInterface, PredicateJsonForm } from './Interface'
export class NotPredicate<T extends PredicateTarget> implements PredicateInterface<T> {
constructor(public readonly predicate: PredicateInterface<T>) {}
matchesItem(item: T): boolean {
return !this.predicate.matchesItem(item)
}
keypathIncludesString(verb: string): boolean {
return this.predicate.keypathIncludesString(verb)
}
toJson(): PredicateJsonForm {
return {
operator: 'not',
value: this.predicate.toJson(),
}
}
}

View File

@@ -0,0 +1,95 @@
import { isString } from '@standardnotes/utils'
import { FalseyValue, PredicateOperator, PrimitiveOperand, SureValue } from './Interface'
import { dateFromDSLDateString } from './Utils'
export function valueMatchesTargetValue(
value: PrimitiveOperand,
operator: PredicateOperator,
targetValue: SureValue,
): boolean {
if (targetValue == undefined) {
return false
}
if (typeof targetValue === 'string' && targetValue.includes('.ago')) {
targetValue = dateFromDSLDateString(targetValue)
}
if (typeof targetValue === 'string') {
targetValue = targetValue.toLowerCase()
}
if (typeof value === 'string') {
value = value.toLowerCase()
}
if (operator === 'not') {
return !valueMatchesTargetValue(value, '=', targetValue)
}
const falseyValues = [false, '', null, undefined, NaN]
if (value == undefined) {
const isExpectingFalseyValue = falseyValues.includes(targetValue as FalseyValue)
if (operator === '!=') {
return !isExpectingFalseyValue
} else {
return isExpectingFalseyValue
}
}
if (operator === '=') {
if (Array.isArray(value)) {
return JSON.stringify(value) === JSON.stringify(targetValue)
} else {
return value === targetValue
}
}
if (operator === '!=') {
if (Array.isArray(value)) {
return JSON.stringify(value) !== JSON.stringify(targetValue)
} else {
return value !== targetValue
}
}
if (operator === '<') {
return (value as number) < (targetValue as number)
}
if (operator === '>') {
return (value as number) > (targetValue as number)
}
if (operator === '<=') {
return (value as number) <= (targetValue as number)
}
if (operator === '>=') {
return (value as number) >= (targetValue as number)
}
if (operator === 'startsWith') {
return (value as string).startsWith(targetValue as string)
}
if (operator === 'in' && Array.isArray(targetValue)) {
return (targetValue as SureValue[]).includes(value)
}
if (operator === 'includes') {
if (isString(value)) {
return value.includes(targetValue as string)
}
if (isString(targetValue) && (isString(value) || Array.isArray(value))) {
return (value as SureValue[]).includes(targetValue)
}
}
if (operator === 'matches') {
const regex = new RegExp(targetValue as string)
return regex.test(value as string)
}
return false
}

View File

@@ -0,0 +1,639 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface'
import { ContentType } from '@standardnotes/common'
import {
compoundPredicateFromArguments,
includesPredicateFromArguments,
notPredicateFromArguments,
predicateFromArguments,
predicateFromDSLString,
} from './Generators'
import { IncludesPredicate } from './IncludesPredicate'
import { Predicate } from './Predicate'
import { CompoundPredicate } from './CompoundPredicate'
import { NotPredicate } from './NotPredicate'
interface Item extends ItemInterface {
content_type: ContentType
updated_at: Date
}
interface Note extends Item {
title: string
text: string
tags: Tag[]
}
interface Tag extends Item {
title: string
}
function createNote(content: Record<string, unknown>, tags?: Tag[]): Note {
return {
...content,
content_type: ContentType.Note,
tags,
} as jest.Mocked<Note>
}
function createTag(title: string): Tag {
return {
title,
content_type: ContentType.Tag,
} as jest.Mocked<Tag>
}
function createItem(content: Record<string, unknown>, updatedAt?: Date): Item {
return {
...content,
updated_at: updatedAt,
content_type: ContentType.Any,
} as jest.Mocked<Note>
}
const createNoteContent = (title = 'Hello', desc = 'World') => {
const params = {
title: title,
text: desc,
}
return params
}
const tags = [createTag('foo'), createTag('bar'), createTag('far')]
describe('predicates', () => {
it('string comparisons should be case insensitive', () => {
const string = '!["Not notes", "title", "startsWith", "foo"]'
const predicate = predicateFromDSLString(string)
const matchingItem1 = createTag('foo')
expect(predicate.matchesItem(matchingItem1)).toEqual(true)
const matchingItem2 = {
title: 'Foo',
} as jest.Mocked<Note>
expect(predicate.matchesItem(matchingItem2)).toEqual(true)
})
describe('includes operator', () => {
let item: Note
beforeEach(() => {
item = createNote(createNoteContent(), tags)
})
it('includes string', () => {
expect(new Predicate<Note>('title', 'includes', 'ello').matchesItem(item)).toEqual(true)
})
})
describe('or operator', () => {
let item: Note
const title = 'Hello'
beforeEach(() => {
item = createNote(createNoteContent(title))
})
it('both matching', () => {
expect(
compoundPredicateFromArguments<Note>('or', [
{ keypath: 'title', operator: '=', value: 'Hello' },
{ keypath: 'content_type', operator: '=', value: ContentType.Note },
]).matchesItem(item),
).toEqual(true)
})
it('first matching', () => {
expect(
compoundPredicateFromArguments<Note>('or', [
{ keypath: 'title', operator: '=', value: 'Hello' },
{ keypath: 'content_type', operator: '=', value: 'Wrong' },
]).matchesItem(item),
).toEqual(true)
})
it('second matching', () => {
expect(
compoundPredicateFromArguments<Note>('or', [
{ keypath: 'title', operator: '=', value: 'Wrong' },
{ keypath: 'content_type', operator: '=', value: ContentType.Note },
]).matchesItem(item),
).toEqual(true)
})
it('both nonmatching', () => {
expect(
compoundPredicateFromArguments<Note>('or', [
{ keypath: 'title', operator: '=', value: 'Wrong' },
{ keypath: 'content_type', operator: '=', value: 'Wrong' },
]).matchesItem(item),
).toEqual(false)
})
})
describe('includes operator', () => {
let item: Note
const title = 'Foo'
beforeEach(() => {
item = createNote(createNoteContent(title), tags)
})
it('all matching', () => {
const predicate = new IncludesPredicate<Note>('tags', new Predicate<Note>('title', 'in', ['sobar', 'foo']))
expect(predicate.matchesItem(item)).toEqual(true)
})
})
describe('and operator', () => {
let item: Note
const title = 'Foo'
beforeEach(() => {
item = createNote(createNoteContent(title))
})
it('all matching', () => {
expect(
compoundPredicateFromArguments<Note>('and', [
{ keypath: 'title', operator: '=', value: title },
{ keypath: 'content_type', operator: '=', value: ContentType.Note },
]).matchesItem(item),
).toEqual(true)
})
it('one matching', () => {
expect(
compoundPredicateFromArguments<Note>('and', [
{ keypath: 'title', operator: '=', value: 'Wrong' },
{ keypath: 'content_type', operator: '=', value: ContentType.Note },
]).matchesItem(item),
).toEqual(false)
})
it('none matching', () => {
expect(
compoundPredicateFromArguments<Note>('and', [
{ keypath: 'title', operator: '=', value: '123' },
{ keypath: 'content_type', operator: '=', value: '456' },
]).matchesItem(item),
).toEqual(false)
})
it('explicit compound syntax', () => {
const compoundProd = new CompoundPredicate('and', [
new Predicate<Note>('title', '=', title),
new Predicate('content_type', '=', ContentType.Note),
])
expect(compoundProd.matchesItem(item)).toEqual(true)
})
})
describe('not operator', function () {
let item: Note
beforeEach(() => {
item = createNote(createNoteContent(), tags)
})
it('basic not predicate', () => {
expect(
new NotPredicate<Note>(
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'far')),
).matchesItem(item),
).toEqual(false)
})
it('recursive compound predicate', () => {
expect(
new CompoundPredicate<Note>('and', [
new NotPredicate<Note>(new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'far'))),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'foo')),
]).matchesItem(item),
).toEqual(false)
})
it('matching basic operator', () => {
expect(
notPredicateFromArguments<Note>({
keypath: 'title',
operator: '=',
value: 'Not This Title',
}).matchesItem(item),
).toEqual(true)
})
it('nonmatching basic operator', () => {
expect(
notPredicateFromArguments<Note>({
keypath: 'title',
operator: '=',
value: 'Hello',
}).matchesItem(item),
).toEqual(false)
})
it('matching compound', () => {
expect(
new CompoundPredicate<Note>('and', [
new NotPredicate<Note>(new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'boo'))),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'foo')),
]).matchesItem(item),
).toEqual(true)
})
it('matching compound includes', () => {
const andPredicate = new CompoundPredicate<Note>('and', [
predicateFromArguments('title', 'startsWith', 'H'),
includesPredicateFromArguments<Note>('tags', {
keypath: 'title',
operator: '=',
value: 'falsify',
}),
])
expect(new NotPredicate<Note>(andPredicate).matchesItem(item)).toEqual(true)
})
it('nonmatching compound includes', () => {
expect(
new NotPredicate<Note>(
new CompoundPredicate<Note>('and', [
new Predicate<Note>('title', 'startsWith', 'H'),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'foo')),
]),
).matchesItem(item),
).toEqual(false)
})
it('nonmatching compound or', () => {
expect(
new NotPredicate<Note>(
new CompoundPredicate<Note>('or', [
new Predicate<Note>('title', 'startsWith', 'H'),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'falsify')),
]),
).matchesItem(item),
).toEqual(false)
})
it('matching compound or', () => {
expect(
new NotPredicate<Note>(
new CompoundPredicate<Note>('or', [
new Predicate<Note>('title', 'startsWith', 'Z'),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'falsify')),
]),
).matchesItem(item),
).toEqual(true)
})
})
describe('regex', () => {
it('matching', () => {
const item = createNote(createNoteContent('abc'))
const onlyLetters = new Predicate<Note>('title', 'matches', '^[a-zA-Z]+$')
expect(onlyLetters.matchesItem(item)).toEqual(true)
})
it('nonmatching', () => {
const item = createNote(createNoteContent('123'))
const onlyLetters = new Predicate<Note>('title', 'matches', '^[a-zA-Z]+$')
expect(onlyLetters.matchesItem(item)).toEqual(false)
})
})
describe('deep recursion', () => {
let item: Note
const title = 'Hello'
beforeEach(() => {
item = createNote(createNoteContent(title))
})
it('matching', () => {
expect(
new CompoundPredicate<Note>('and', [
new Predicate<Note>('title', '=', 'Hello'),
new CompoundPredicate<Note>('or', [
new Predicate<Note>('title', '=', 'Wrong'),
new Predicate<Note>('title', '=', 'Wrong again'),
new Predicate<Note>('title', '=', 'Hello'),
]),
]).matchesItem(item),
).toEqual(true)
})
it('nonmatching', () => {
expect(
new CompoundPredicate<Note>('and', [
new Predicate<Note>('title', '=', 'Hello'),
new CompoundPredicate<Note>('or', [
new Predicate<Note>('title', '=', 'Wrong'),
new Predicate<Note>('title', '=', 'Wrong again'),
new Predicate<Note>('title', '=', 'All wrong'),
]),
]).matchesItem(item),
).toEqual(false)
})
})
describe('inequality operator', () => {
let item: Item
const body = 'Hello'
const numbers = ['1', '2', '3']
beforeEach(() => {
item = createItem({ body, numbers })
})
it('matching', () => {
expect(new Predicate<any>('body', '!=', 'NotBody').matchesItem(item)).toEqual(true)
})
it('nonmatching', () => {
expect(new Predicate<any>('body', '!=', body).matchesItem(item)).toEqual(false)
})
it('matching array', () => {
expect(new Predicate<any>('numbers', '!=', ['1']).matchesItem(item)).toEqual(true)
})
it('nonmatching array', () => {
expect(new Predicate<any>('numbers', '!=', ['1', '2', '3']).matchesItem(item)).toEqual(false)
})
})
describe('equals operator', () => {
let item: Item
const body = 'Hello'
const numbers = ['1', '2', '3']
beforeEach(() => {
item = createItem({ body, numbers })
})
it('matching', () => {
expect(new Predicate<any>('body', '=', body).matchesItem(item)).toEqual(true)
})
it('nonmatching', () => {
expect(new Predicate<any>('body', '=', 'NotBody').matchesItem(item)).toEqual(false)
})
it('false and undefined should be equivalent', () => {
expect(new Predicate<any>('undefinedProperty', '=', false).matchesItem(item)).toEqual(true)
})
it('nonmatching array', () => {
expect(new Predicate<any>('numbers', '=', ['1']).matchesItem(item)).toEqual(false)
})
it('matching array', () => {
expect(new Predicate<any>('numbers', '=', ['1', '2', '3']).matchesItem(item)).toEqual(true)
})
it('nested keypath', () => {
expect(new Predicate<any>('numbers.length', '=', numbers.length).matchesItem(item)).toEqual(true)
})
})
describe('date comparison', () => {
let item: Item
const date = new Date()
beforeEach(() => {
item = createItem({}, date)
})
it('nonmatching date value', () => {
const date = new Date()
date.setSeconds(date.getSeconds() + 1)
const predicate = new Predicate('updated_at', '>', date)
expect(predicate.matchesItem(item)).toEqual(false)
})
it('matching date value', () => {
const date = new Date()
date.setSeconds(date.getSeconds() + 1)
const predicate = new Predicate('updated_at', '<', date)
expect(predicate.matchesItem(item)).toEqual(true)
})
it('matching days ago value', () => {
expect(new Predicate('updated_at', '>', '30.days.ago').matchesItem(item)).toEqual(true)
})
it('nonmatching days ago value', () => {
expect(new Predicate('updated_at', '<', '30.days.ago').matchesItem(item)).toEqual(false)
})
it('hours ago value', () => {
expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true)
})
})
describe('nonexistent properties', () => {
let item: Item
beforeEach(() => {
item = createItem({})
})
it('nested keypath', () => {
expect(new Predicate<any>('foobar.length', '=', 0).matchesItem(item)).toEqual(false)
})
it('inequality operator', () => {
expect(new Predicate<any>('foobar', '!=', 'NotFoo').matchesItem(item)).toEqual(true)
})
it('equals operator', () => {
expect(new Predicate<any>('foobar', '=', 'NotFoo').matchesItem(item)).toEqual(false)
})
it('less than operator', () => {
expect(new Predicate<any>('foobar', '<', 3).matchesItem(item)).toEqual(false)
})
it('greater than operator', () => {
expect(new Predicate<any>('foobar', '>', 3).matchesItem(item)).toEqual(false)
})
it('less than or equal to operator', () => {
expect(new Predicate<any>('foobar', '<=', 3).matchesItem(item)).toEqual(false)
})
it('includes operator', () => {
expect(new Predicate<any>('foobar', 'includes', 3).matchesItem(item)).toEqual(false)
})
})
describe('toJson', () => {
it('basic predicate', () => {
const predicate = new Predicate<Note>('title', 'startsWith', 'H')
const json = predicate.toJson()
expect(json).toStrictEqual({
keypath: 'title',
operator: 'startsWith',
value: 'H',
})
})
it('compound and', () => {
const predicate = new CompoundPredicate<Note>('and', [
new Predicate<Note>('title', 'startsWith', 'H'),
new Predicate<Note>('title', '=', 'Hello'),
])
const json = predicate.toJson()
expect(json).toStrictEqual({
operator: 'and',
value: [
{
keypath: 'title',
operator: 'startsWith',
value: 'H',
},
{
keypath: 'title',
operator: '=',
value: 'Hello',
},
],
})
})
it('not', () => {
const predicate = new NotPredicate<Note>(new Predicate<Note>('title', 'startsWith', 'H'))
const json = predicate.toJson()
expect(json).toStrictEqual({
operator: 'not',
value: {
keypath: 'title',
operator: 'startsWith',
value: 'H',
},
})
})
it('not compound', () => {
const predicate = new NotPredicate<Note>(
new CompoundPredicate<Note>('or', [
new Predicate<Note>('title', 'startsWith', 'H'),
new IncludesPredicate<Note>('tags', new Predicate<Tag>('title', '=', 'falsify')),
]),
)
const json = predicate.toJson()
expect(json).toStrictEqual({
operator: 'not',
value: {
operator: 'or',
value: [
{
keypath: 'title',
operator: 'startsWith',
value: 'H',
},
{
keypath: 'tags',
operator: 'includes',
value: {
keypath: 'title',
operator: '=',
value: 'falsify',
},
},
],
},
})
})
})
describe('generators', () => {
it('includes predicate', () => {
const json = ['B-tags', 'tags', 'includes', ['title', 'startsWith', 'b']]
const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as IncludesPredicate<Item>
expect(predicate).toBeInstanceOf(IncludesPredicate)
expect(predicate.predicate).toBeInstanceOf(Predicate)
expect((predicate.predicate as Predicate<Item>).keypath).toEqual('title')
expect((predicate.predicate as Predicate<Item>).operator).toEqual('startsWith')
})
it('includes string should be mapped to normal predicate', () => {
const json = ['TODO', 'title', 'includes', 'TODO']
const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as Predicate<Item>
expect(predicate).toBeInstanceOf(Predicate)
expect(predicate.keypath).toEqual('title')
expect(predicate.operator).toEqual('includes')
})
it('complex compound and', () => {
const json = [
'label',
'ignored_keypath',
'and',
[
['', 'not', ['tags', 'includes', ['title', '=', 'boo']]],
['tags', 'includes', ['title', '=', 'foo']],
],
]
const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as CompoundPredicate<Item>
expect(predicate).toBeInstanceOf(CompoundPredicate)
expect(predicate.predicates).toHaveLength(2)
const notPredicate = predicate.predicates[0] as NotPredicate<Item>
expect(notPredicate).toBeInstanceOf(NotPredicate)
const includesPredicate = predicate.predicates[1]
expect(includesPredicate).toBeInstanceOf(IncludesPredicate)
expect(notPredicate.predicate).toBeInstanceOf(IncludesPredicate)
expect((notPredicate.predicate as IncludesPredicate<Item>).predicate).toBeInstanceOf(Predicate)
})
it('nested compound or', () => {
const json = [
'label',
'ignored_keypath',
'and',
[
['title', '=', 'Hello'],
[
'this_field_ignored',
'or',
[
['title', '=', 'Wrong'],
['title', '=', 'Wrong again'],
['title', '=', 'All wrong'],
],
],
],
]
const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as CompoundPredicate<Item>
expect(predicate).toBeInstanceOf(CompoundPredicate)
expect(predicate.predicates).toHaveLength(2)
expect(predicate.predicates[0]).toBeInstanceOf(Predicate)
const orPredicate = predicate.predicates[1] as CompoundPredicate<Item>
expect(orPredicate).toBeInstanceOf(CompoundPredicate)
expect(orPredicate.predicates).toHaveLength(3)
expect(orPredicate.operator).toEqual('or')
for (const subPredicate of orPredicate.predicates) {
expect(subPredicate).toBeInstanceOf(Predicate)
}
})
})
})

View File

@@ -0,0 +1,49 @@
import {
PredicateTarget,
PredicateInterface,
PredicateJsonForm,
PredicateOperator,
PrimitiveOperand,
StringKey,
SureValue,
} from './Interface'
import { valueMatchesTargetValue } from './Operator'
/**
* A local-only construct that defines a built query that
* can be used to dynamically search items.
*/
export class Predicate<T extends PredicateTarget> implements PredicateInterface<T> {
constructor(
public readonly keypath: StringKey<T>,
public readonly operator: PredicateOperator,
public readonly targetValue: SureValue,
) {
if (this.targetValue === 'true' || this.targetValue === 'false') {
this.targetValue = JSON.parse(this.targetValue)
}
}
keypathIncludesString(verb: string): boolean {
return (this.keypath as string).includes(verb)
}
matchesItem<T extends PredicateTarget>(item: T): boolean {
const keyPathComponents = this.keypath.split('.') as StringKey<T>[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valueAtKeyPath: PrimitiveOperand = keyPathComponents.reduce<any>((previous, current) => {
return previous && previous[current]
}, item)
return valueMatchesTargetValue(valueAtKeyPath, this.operator, this.targetValue)
}
toJson(): PredicateJsonForm {
return {
keypath: this.keypath,
operator: this.operator,
value: this.targetValue,
}
}
}

View File

@@ -0,0 +1,15 @@
/**
* Predicate date strings are of form "x.days.ago" or "x.hours.ago"
*/
export function dateFromDSLDateString(string: string): Date {
const comps = string.split('.')
const unit = comps[1]
const date = new Date()
const offset = parseInt(comps[0])
if (unit === 'days') {
date.setDate(date.getDate() - offset)
} else if (unit === 'hours') {
date.setHours(date.getHours() - offset)
}
return date
}