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