feat: add models package
This commit is contained in:
51
packages/models/src/Domain/Abstract/Content/ItemContent.ts
Normal file
51
packages/models/src/Domain/Abstract/Content/ItemContent.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { AppData, DefaultAppDomain } from '../Item/Types/DefaultAppDomain'
|
||||
import { ContentReference } from '../Reference/ContentReference'
|
||||
import { AppDataField } from '../Item/Types/AppDataField'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface SpecializedContent {}
|
||||
|
||||
export interface ItemContent {
|
||||
references: ContentReference[]
|
||||
conflict_of?: Uuid
|
||||
protected?: boolean
|
||||
trashed?: boolean
|
||||
pinned?: boolean
|
||||
archived?: boolean
|
||||
locked?: boolean
|
||||
appData?: AppData
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the input object to fill in any missing required values from the
|
||||
* content body.
|
||||
*/
|
||||
|
||||
export function FillItemContent<C extends ItemContent = ItemContent>(content: Partial<C>): C {
|
||||
if (!content.references) {
|
||||
content.references = []
|
||||
}
|
||||
|
||||
if (!content.appData) {
|
||||
content.appData = {
|
||||
[DefaultAppDomain]: {},
|
||||
}
|
||||
}
|
||||
|
||||
if (!content.appData[DefaultAppDomain]) {
|
||||
content.appData[DefaultAppDomain] = {}
|
||||
}
|
||||
|
||||
if (!content.appData[DefaultAppDomain][AppDataField.UserModifiedDate]) {
|
||||
content.appData[DefaultAppDomain][AppDataField.UserModifiedDate] = `${new Date()}`
|
||||
}
|
||||
|
||||
return content as C
|
||||
}
|
||||
|
||||
export function FillItemContentSpecialized<S extends SpecializedContent, C extends ItemContent = ItemContent>(
|
||||
content: S,
|
||||
): C {
|
||||
return FillItemContent(content)
|
||||
}
|
||||
60
packages/models/src/Domain/Abstract/Contextual/BackupFile.ts
Normal file
60
packages/models/src/Domain/Abstract/Contextual/BackupFile.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { DecryptedTransferPayload, EncryptedTransferPayload } from '../TransferPayload'
|
||||
|
||||
export interface BackupFileEncryptedContextualPayload extends ContextPayload {
|
||||
auth_hash?: string
|
||||
content: string
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
duplicate_of?: Uuid
|
||||
enc_item_key: string
|
||||
items_key_id: string | undefined
|
||||
updated_at: Date
|
||||
updated_at_timestamp: number
|
||||
}
|
||||
|
||||
export interface BackupFileDecryptedContextualPayload<C extends ItemContent = ItemContent> extends ContextPayload {
|
||||
content: C
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
duplicate_of?: Uuid
|
||||
updated_at: Date
|
||||
updated_at_timestamp: number
|
||||
}
|
||||
|
||||
export function CreateEncryptedBackupFileContextPayload(
|
||||
fromPayload: EncryptedTransferPayload,
|
||||
): BackupFileEncryptedContextualPayload {
|
||||
return {
|
||||
auth_hash: fromPayload.auth_hash,
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
enc_item_key: fromPayload.enc_item_key,
|
||||
items_key_id: fromPayload.items_key_id,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateDecryptedBackupFileContextPayload(
|
||||
fromPayload: DecryptedTransferPayload,
|
||||
): BackupFileDecryptedContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { DecryptedTransferPayload } from '../TransferPayload'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
|
||||
/**
|
||||
* Represents a payload with permissible fields for when a
|
||||
* component wants to create a new item
|
||||
*/
|
||||
export interface ComponentCreateContextualPayload<C extends ItemContent = ItemContent> extends ContextPayload {
|
||||
content: C
|
||||
created_at?: Date
|
||||
}
|
||||
|
||||
export function createComponentCreatedContextPayload(
|
||||
fromPayload: DecryptedTransferPayload,
|
||||
): ComponentCreateContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { DecryptedTransferPayload } from '../TransferPayload'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
|
||||
/**
|
||||
* Represents a payload with permissible fields for when a
|
||||
* payload is retrieved from a component for saving
|
||||
*/
|
||||
export interface ComponentRetrievedContextualPayload<C extends ItemContent = ItemContent> extends ContextPayload {
|
||||
content: C
|
||||
created_at?: Date
|
||||
}
|
||||
|
||||
export function CreateComponentRetrievedContextPayload(
|
||||
fromPayload: DecryptedTransferPayload,
|
||||
): ComponentRetrievedContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
|
||||
export interface ContextPayload<C extends ItemContent = ItemContent> {
|
||||
uuid: string
|
||||
content_type: ContentType
|
||||
content: C | string | undefined
|
||||
deleted: boolean
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ServerItemResponse } from '@standardnotes/responses'
|
||||
import { isCorruptTransferPayload, isEncryptedTransferPayload } from '../TransferPayload'
|
||||
|
||||
export interface FilteredServerItem extends ServerItemResponse {
|
||||
__passed_filter__: true
|
||||
}
|
||||
|
||||
export function CreateFilteredServerItem(item: ServerItemResponse): FilteredServerItem {
|
||||
return {
|
||||
...item,
|
||||
__passed_filter__: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function FilterDisallowedRemotePayloadsAndMap(payloads: ServerItemResponse[]): FilteredServerItem[] {
|
||||
return payloads.filter(isRemotePayloadAllowed).map(CreateFilteredServerItem)
|
||||
}
|
||||
|
||||
export function isRemotePayloadAllowed(payload: ServerItemResponse): boolean {
|
||||
if (isCorruptTransferPayload(payload)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isEncryptedTransferPayload(payload) || payload.content == undefined
|
||||
}
|
||||
107
packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts
Normal file
107
packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { DecryptedPayloadInterface, DeletedPayloadInterface, EncryptedPayloadInterface } from '../Payload'
|
||||
import { useBoolean } from '@standardnotes/utils'
|
||||
import { EncryptedTransferPayload, isEncryptedTransferPayload } from '../TransferPayload'
|
||||
|
||||
export function isEncryptedLocalStoragePayload(
|
||||
p: LocalStorageEncryptedContextualPayload | LocalStorageDecryptedContextualPayload,
|
||||
): p is LocalStorageEncryptedContextualPayload {
|
||||
return isEncryptedTransferPayload(p as EncryptedTransferPayload)
|
||||
}
|
||||
|
||||
export interface LocalStorageEncryptedContextualPayload extends ContextPayload {
|
||||
auth_hash?: string
|
||||
auth_params?: unknown
|
||||
content: string
|
||||
deleted: false
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
dirty: boolean
|
||||
duplicate_of: Uuid | undefined
|
||||
enc_item_key: string
|
||||
errorDecrypting: boolean
|
||||
items_key_id: string | undefined
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
waitingForKey: boolean
|
||||
}
|
||||
|
||||
export interface LocalStorageDecryptedContextualPayload<C extends ItemContent = ItemContent> extends ContextPayload {
|
||||
content: C
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
deleted: false
|
||||
dirty: boolean
|
||||
duplicate_of?: Uuid
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export interface LocalStorageDeletedContextualPayload extends ContextPayload {
|
||||
content: undefined
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
deleted: true
|
||||
dirty: true
|
||||
duplicate_of?: Uuid
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export function CreateEncryptedLocalStorageContextPayload(
|
||||
fromPayload: EncryptedPayloadInterface,
|
||||
): LocalStorageEncryptedContextualPayload {
|
||||
return {
|
||||
auth_hash: fromPayload.auth_hash,
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
dirty: fromPayload.dirty != undefined ? fromPayload.dirty : false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
enc_item_key: fromPayload.enc_item_key,
|
||||
errorDecrypting: fromPayload.errorDecrypting,
|
||||
items_key_id: fromPayload.items_key_id,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
waitingForKey: fromPayload.waitingForKey,
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateDecryptedLocalStorageContextPayload(
|
||||
fromPayload: DecryptedPayloadInterface,
|
||||
): LocalStorageDecryptedContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
content: fromPayload.content,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
dirty: useBoolean(fromPayload.dirty, false),
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateDeletedLocalStorageContextPayload(
|
||||
fromPayload: DeletedPayloadInterface,
|
||||
): LocalStorageDeletedContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
content: undefined,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: true,
|
||||
dirty: true,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { DecryptedPayloadInterface, DeletedPayloadInterface, isDecryptedPayload } from '../Payload'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
|
||||
export interface OfflineSyncPushContextualPayload extends ContextPayload {
|
||||
content: ItemContent | undefined
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
duplicate_of?: Uuid
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export function CreateOfflineSyncPushContextPayload(
|
||||
fromPayload: DecryptedPayloadInterface | DeletedPayloadInterface,
|
||||
): OfflineSyncPushContextualPayload {
|
||||
const base: OfflineSyncPushContextualPayload = {
|
||||
content: undefined,
|
||||
content_type: fromPayload.content_type,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
|
||||
if (isDecryptedPayload(fromPayload)) {
|
||||
return {
|
||||
...base,
|
||||
content: fromPayload.content,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...base,
|
||||
deleted: fromPayload.deleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { DecryptedPayloadInterface, DeletedPayloadInterface, isDeletedPayload } from '../Payload'
|
||||
|
||||
/**
|
||||
* The saved sync item payload represents the payload we want to map
|
||||
* when mapping saved_items from the server or local sync mechanism. We only want to map the
|
||||
* updated_at value the server returns for the item, and basically
|
||||
* nothing else.
|
||||
*/
|
||||
export interface OfflineSyncSavedContextualPayload {
|
||||
content_type: ContentType
|
||||
created_at_timestamp: number
|
||||
deleted: boolean
|
||||
updated_at_timestamp?: number
|
||||
updated_at: Date
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export function CreateOfflineSyncSavedPayload(
|
||||
fromPayload: DecryptedPayloadInterface | DeletedPayloadInterface,
|
||||
): OfflineSyncSavedContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
deleted: isDeletedPayload(fromPayload),
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { DeletedPayloadInterface, EncryptedPayloadInterface } from '../Payload'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
|
||||
export interface ServerSyncPushContextualPayload extends ContextPayload {
|
||||
auth_hash?: string
|
||||
content: string | undefined
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
duplicate_of?: Uuid
|
||||
enc_item_key?: string
|
||||
items_key_id?: string
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export function CreateEncryptedServerSyncPushPayload(
|
||||
fromPayload: EncryptedPayloadInterface,
|
||||
): ServerSyncPushContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: false,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
content: fromPayload.content,
|
||||
enc_item_key: fromPayload.enc_item_key,
|
||||
items_key_id: fromPayload.items_key_id,
|
||||
auth_hash: fromPayload.auth_hash,
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateDeletedServerSyncPushPayload(
|
||||
fromPayload: DeletedPayloadInterface,
|
||||
): ServerSyncPushContextualPayload {
|
||||
return {
|
||||
content_type: fromPayload.content_type,
|
||||
created_at_timestamp: fromPayload.created_at_timestamp,
|
||||
created_at: fromPayload.created_at,
|
||||
deleted: true,
|
||||
duplicate_of: fromPayload.duplicate_of,
|
||||
updated_at_timestamp: fromPayload.updated_at_timestamp,
|
||||
updated_at: fromPayload.updated_at,
|
||||
uuid: fromPayload.uuid,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useBoolean } from '@standardnotes/utils'
|
||||
import { FilteredServerItem } from './FilteredServerItem'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
|
||||
/**
|
||||
* The saved sync item payload represents the payload we want to map
|
||||
* when mapping saved_items from the server. We only want to map the
|
||||
* updated_at value the server returns for the item, and basically
|
||||
* nothing else.
|
||||
*/
|
||||
export interface ServerSyncSavedContextualPayload {
|
||||
content_type: ContentType
|
||||
created_at_timestamp: number
|
||||
created_at: Date
|
||||
deleted: boolean
|
||||
updated_at_timestamp: number
|
||||
updated_at: Date
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export function CreateServerSyncSavedPayload(from: FilteredServerItem): ServerSyncSavedContextualPayload {
|
||||
return {
|
||||
content_type: from.content_type,
|
||||
created_at_timestamp: from.created_at_timestamp,
|
||||
created_at: from.created_at,
|
||||
deleted: useBoolean(from.deleted, false),
|
||||
updated_at_timestamp: from.updated_at_timestamp,
|
||||
updated_at: from.updated_at,
|
||||
uuid: from.uuid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ItemContent } from '../Content/ItemContent'
|
||||
import { ContextPayload } from './ContextPayload'
|
||||
|
||||
export interface SessionHistoryContextualPayload<C extends ItemContent = ItemContent> extends ContextPayload {
|
||||
content: C
|
||||
updated_at: Date
|
||||
}
|
||||
10
packages/models/src/Domain/Abstract/Contextual/index.ts
Normal file
10
packages/models/src/Domain/Abstract/Contextual/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './ComponentCreate'
|
||||
export * from './ComponentRetrieved'
|
||||
export * from './BackupFile'
|
||||
export * from './LocalStorage'
|
||||
export * from './OfflineSyncPush'
|
||||
export * from './OfflineSyncSaved'
|
||||
export * from './ServerSyncPush'
|
||||
export * from './SessionHistory'
|
||||
export * from './ServerSyncSaved'
|
||||
export * from './FilteredServerItem'
|
||||
@@ -0,0 +1,122 @@
|
||||
import { dateToLocalizedString, useBoolean } from '@standardnotes/utils'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { DecryptedTransferPayload } from './../../TransferPayload/Interfaces/DecryptedTransferPayload'
|
||||
import { AppDataField } from '../Types/AppDataField'
|
||||
import { ComponentDataDomain, DefaultAppDomain } from '../Types/DefaultAppDomain'
|
||||
import { DecryptedItemInterface } from '../Interfaces/DecryptedItem'
|
||||
import { DecryptedPayloadInterface } from '../../Payload/Interfaces/DecryptedPayload'
|
||||
import { GenericItem } from './GenericItem'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { ItemContentsEqual } from '../../../Utilities/Item/ItemContentsEqual'
|
||||
import { PrefKey } from '../../../Syncable/UserPrefs/PrefKey'
|
||||
import { ContentReference } from '../../Reference/ContentReference'
|
||||
|
||||
export class DecryptedItem<C extends ItemContent = ItemContent>
|
||||
extends GenericItem<DecryptedPayloadInterface<C>>
|
||||
implements DecryptedItemInterface<C>
|
||||
{
|
||||
public readonly conflictOf?: Uuid
|
||||
public readonly protected: boolean = false
|
||||
public readonly trashed: boolean = false
|
||||
public readonly pinned: boolean = false
|
||||
public readonly archived: boolean = false
|
||||
public readonly locked: boolean = false
|
||||
|
||||
constructor(payload: DecryptedPayloadInterface<C>) {
|
||||
super(payload)
|
||||
this.conflictOf = payload.content.conflict_of
|
||||
|
||||
const userModVal = this.getAppDomainValueWithDefault(AppDataField.UserModifiedDate, this.serverUpdatedAt || 0)
|
||||
|
||||
this.userModifiedDate = new Date(userModVal as number | Date)
|
||||
this.updatedAtString = dateToLocalizedString(this.userModifiedDate)
|
||||
this.protected = useBoolean(this.payload.content.protected, false)
|
||||
this.trashed = useBoolean(this.payload.content.trashed, false)
|
||||
this.pinned = this.getAppDomainValueWithDefault(AppDataField.Pinned, false)
|
||||
this.archived = this.getAppDomainValueWithDefault(AppDataField.Archived, false)
|
||||
this.locked = this.getAppDomainValueWithDefault(AppDataField.Locked, false)
|
||||
}
|
||||
|
||||
public static DefaultAppDomain() {
|
||||
return DefaultAppDomain
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this.payload.content
|
||||
}
|
||||
|
||||
get references(): ContentReference[] {
|
||||
return this.payload.content.references || []
|
||||
}
|
||||
|
||||
public isReferencingItem(item: DecryptedItemInterface): boolean {
|
||||
return this.references.find((r) => r.uuid === item.uuid) != undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside of content is a record called `appData` (which should have been called `domainData`).
|
||||
* It was named `appData` as a way to indicate that it can house data for multiple apps.
|
||||
* Each key of appData is a domain string, which was originally designed
|
||||
* to allow for multiple 3rd party apps who share access to the same data to store data
|
||||
* in an isolated location. This design premise is antiquited and no longer pursued,
|
||||
* however we continue to use it as not to uncesesarily create a large data migration
|
||||
* that would require users to sync all their data.
|
||||
*
|
||||
* domainData[DomainKey] will give you another Record<string, any>.
|
||||
*
|
||||
* Currently appData['org.standardnotes.sn'] returns an object of type AppData.
|
||||
* And appData['org.standardnotes.sn.components] returns an object of type ComponentData
|
||||
*/
|
||||
public getDomainData(
|
||||
domain: typeof ComponentDataDomain | typeof DefaultAppDomain,
|
||||
): undefined | Record<string, unknown> {
|
||||
const domainData = this.payload.content.appData
|
||||
if (!domainData) {
|
||||
return undefined
|
||||
}
|
||||
const data = domainData[domain]
|
||||
return data
|
||||
}
|
||||
|
||||
public getAppDomainValue<T>(key: AppDataField | PrefKey): T | undefined {
|
||||
const appData = this.getDomainData(DefaultAppDomain)
|
||||
return appData?.[key] as T
|
||||
}
|
||||
|
||||
public getAppDomainValueWithDefault<T, D extends T>(key: AppDataField | PrefKey, defaultValue: D): T {
|
||||
const appData = this.getDomainData(DefaultAppDomain)
|
||||
return (appData?.[key] as T) || defaultValue
|
||||
}
|
||||
|
||||
public override payloadRepresentation(override?: Partial<DecryptedTransferPayload<C>>): DecryptedPayloadInterface<C> {
|
||||
return this.payload.copy(override)
|
||||
}
|
||||
|
||||
/**
|
||||
* During sync conflicts, when determing whether to create a duplicate for an item,
|
||||
* we can omit keys that have no meaningful weight and can be ignored. For example,
|
||||
* if one component has active = true and another component has active = false,
|
||||
* it would be needless to duplicate them, so instead we ignore that value.
|
||||
*/
|
||||
public contentKeysToIgnoreWhenCheckingEquality<C extends ItemContent = ItemContent>(): (keyof C)[] {
|
||||
return ['conflict_of']
|
||||
}
|
||||
|
||||
/** Same as `contentKeysToIgnoreWhenCheckingEquality`, but keys inside appData[Item.AppDomain] */
|
||||
public appDataContentKeysToIgnoreWhenCheckingEquality(): AppDataField[] {
|
||||
return [AppDataField.UserModifiedDate]
|
||||
}
|
||||
|
||||
public getContentCopy() {
|
||||
return JSON.parse(JSON.stringify(this.content))
|
||||
}
|
||||
|
||||
public isItemContentEqualWith(otherItem: DecryptedItemInterface) {
|
||||
return ItemContentsEqual(
|
||||
this.payload.content,
|
||||
otherItem.payload.content,
|
||||
this.contentKeysToIgnoreWhenCheckingEquality(),
|
||||
this.appDataContentKeysToIgnoreWhenCheckingEquality(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { GenericItem } from './GenericItem'
|
||||
import { DeletedPayloadInterface } from '../../Payload'
|
||||
import { DeletedItemInterface } from '../Interfaces/DeletedItem'
|
||||
import { DeletedTransferPayload } from '../../TransferPayload'
|
||||
|
||||
export class DeletedItem extends GenericItem<DeletedPayloadInterface> implements DeletedItemInterface {
|
||||
deleted: true
|
||||
content: undefined
|
||||
|
||||
constructor(payload: DeletedPayloadInterface) {
|
||||
super(payload)
|
||||
this.deleted = true
|
||||
}
|
||||
|
||||
public override payloadRepresentation(override?: Partial<DeletedTransferPayload>): DeletedPayloadInterface {
|
||||
return this.payload.copy(override)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { EncryptedTransferPayload } from './../../TransferPayload/Interfaces/EncryptedTransferPayload'
|
||||
import { EncryptedItemInterface } from '../Interfaces/EncryptedItem'
|
||||
import { EncryptedPayloadInterface } from '../../Payload/Interfaces/EncryptedPayload'
|
||||
import { GenericItem } from './GenericItem'
|
||||
|
||||
export class EncryptedItem extends GenericItem<EncryptedPayloadInterface> implements EncryptedItemInterface {
|
||||
constructor(payload: EncryptedPayloadInterface) {
|
||||
super(payload)
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this.payload.version
|
||||
}
|
||||
|
||||
public override payloadRepresentation(override?: Partial<EncryptedTransferPayload>): EncryptedPayloadInterface {
|
||||
return this.payload.copy(override)
|
||||
}
|
||||
|
||||
get errorDecrypting() {
|
||||
return this.payload.errorDecrypting
|
||||
}
|
||||
|
||||
get waitingForKey() {
|
||||
return this.payload.waitingForKey
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this.payload.content
|
||||
}
|
||||
|
||||
get auth_hash() {
|
||||
return this.payload.auth_hash
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import { dateToLocalizedString, deepFreeze } from '@standardnotes/utils'
|
||||
import { TransferPayload } from './../../TransferPayload/Interfaces/TransferPayload'
|
||||
import { ItemContentsDiffer } from '../../../Utilities/Item/ItemContentsDiffer'
|
||||
import { ItemInterface } from '../Interfaces/ItemInterface'
|
||||
import { PayloadSource } from '../../Payload/Types/PayloadSource'
|
||||
import { ConflictStrategy } from '../Types/ConflictStrategy'
|
||||
import { PredicateInterface } from '../../../Runtime/Predicate/Interface'
|
||||
import { SingletonStrategy } from '../Types/SingletonStrategy'
|
||||
import { PayloadInterface } from '../../Payload/Interfaces/PayloadInterface'
|
||||
import { HistoryEntryInterface } from '../../../Runtime/History/HistoryEntryInterface'
|
||||
import { isDecryptedItem, isDeletedItem, isEncryptedErroredItem } from '../Interfaces/TypeCheck'
|
||||
|
||||
export abstract class GenericItem<P extends PayloadInterface = PayloadInterface> implements ItemInterface<P> {
|
||||
payload: P
|
||||
public readonly duplicateOf?: Uuid
|
||||
public readonly createdAtString?: string
|
||||
public updatedAtString?: string
|
||||
public userModifiedDate: Date
|
||||
|
||||
constructor(payload: P) {
|
||||
this.payload = payload
|
||||
this.duplicateOf = payload.duplicate_of
|
||||
this.createdAtString = this.created_at && dateToLocalizedString(this.created_at)
|
||||
this.userModifiedDate = this.serverUpdatedAt || new Date()
|
||||
this.updatedAtString = dateToLocalizedString(this.userModifiedDate)
|
||||
|
||||
const timeToAllowSubclassesToFinishConstruction = 0
|
||||
setTimeout(() => {
|
||||
deepFreeze(this)
|
||||
}, timeToAllowSubclassesToFinishConstruction)
|
||||
}
|
||||
|
||||
get uuid() {
|
||||
return this.payload.uuid
|
||||
}
|
||||
|
||||
get content_type(): ContentType {
|
||||
return this.payload.content_type
|
||||
}
|
||||
|
||||
get created_at() {
|
||||
return this.payload.created_at
|
||||
}
|
||||
|
||||
/**
|
||||
* The date timestamp the server set for this item upon it being synced
|
||||
* Undefined if never synced to a remote server.
|
||||
*/
|
||||
public get serverUpdatedAt(): Date {
|
||||
return this.payload.serverUpdatedAt
|
||||
}
|
||||
|
||||
public get serverUpdatedAtTimestamp(): number | undefined {
|
||||
return this.payload.updated_at_timestamp
|
||||
}
|
||||
|
||||
/** @deprecated Use serverUpdatedAt instead */
|
||||
public get updated_at(): Date | undefined {
|
||||
return this.serverUpdatedAt
|
||||
}
|
||||
|
||||
get dirty() {
|
||||
return this.payload.dirty
|
||||
}
|
||||
|
||||
get lastSyncBegan() {
|
||||
return this.payload.lastSyncBegan
|
||||
}
|
||||
|
||||
get lastSyncEnd() {
|
||||
return this.payload.lastSyncEnd
|
||||
}
|
||||
|
||||
get duplicate_of() {
|
||||
return this.payload.duplicate_of
|
||||
}
|
||||
|
||||
public payloadRepresentation(override?: Partial<TransferPayload>): P {
|
||||
return this.payload.copy(override)
|
||||
}
|
||||
|
||||
/** Whether the item has never been synced to a server */
|
||||
public get neverSynced(): boolean {
|
||||
return !this.serverUpdatedAt || this.serverUpdatedAt.getTime() === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses can override this getter to return true if they want only
|
||||
* one of this item to exist, depending on custom criteria.
|
||||
*/
|
||||
public get isSingleton(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** The predicate by which singleton items should be unique */
|
||||
public singletonPredicate<T extends ItemInterface>(): PredicateInterface<T> {
|
||||
throw 'Must override SNItem.singletonPredicate'
|
||||
}
|
||||
|
||||
public get singletonStrategy(): SingletonStrategy {
|
||||
return SingletonStrategy.KeepEarliest
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses can override this method and provide their own opinion on whether
|
||||
* they want to be duplicated. For example, if this.content.x = 12 and
|
||||
* item.content.x = 13, this function can be overriden to always return
|
||||
* ConflictStrategy.KeepBase to say 'don't create a duplicate at all, the
|
||||
* change is not important.'
|
||||
*
|
||||
* In the default implementation, we create a duplicate if content differs.
|
||||
* However, if they only differ by references, we KEEP_LEFT_MERGE_REFS.
|
||||
*
|
||||
* Left returns to our current item, and Right refers to the incoming item.
|
||||
*/
|
||||
public strategyWhenConflictingWithItem(
|
||||
item: ItemInterface,
|
||||
previousRevision?: HistoryEntryInterface,
|
||||
): ConflictStrategy {
|
||||
if (isEncryptedErroredItem(this)) {
|
||||
return ConflictStrategy.KeepBaseDuplicateApply
|
||||
}
|
||||
|
||||
if (this.isSingleton) {
|
||||
return ConflictStrategy.KeepBase
|
||||
}
|
||||
|
||||
if (isDeletedItem(this)) {
|
||||
return ConflictStrategy.KeepApply
|
||||
}
|
||||
|
||||
if (isDeletedItem(item)) {
|
||||
if (this.payload.source === PayloadSource.FileImport) {
|
||||
return ConflictStrategy.KeepBase
|
||||
}
|
||||
return ConflictStrategy.KeepApply
|
||||
}
|
||||
|
||||
if (!isDecryptedItem(item) || !isDecryptedItem(this)) {
|
||||
return ConflictStrategy.KeepBaseDuplicateApply
|
||||
}
|
||||
|
||||
const contentDiffers = ItemContentsDiffer(this, item)
|
||||
if (!contentDiffers) {
|
||||
return ConflictStrategy.KeepApply
|
||||
}
|
||||
|
||||
const itemsAreDifferentExcludingRefs = ItemContentsDiffer(this, item, ['references'])
|
||||
if (itemsAreDifferentExcludingRefs) {
|
||||
if (previousRevision) {
|
||||
/**
|
||||
* If previousRevision.content === incomingValue.content, this means the
|
||||
* change that was rejected by the server is in fact a legitimate change,
|
||||
* because the value the client had previously matched with the server's,
|
||||
* and this new change is being built on top of that state, and should therefore
|
||||
* be chosen as the winner, with no need for a conflict.
|
||||
*/
|
||||
if (!ItemContentsDiffer(previousRevision.itemFromPayload(), item)) {
|
||||
return ConflictStrategy.KeepBase
|
||||
}
|
||||
}
|
||||
const twentySeconds = 20_000
|
||||
if (
|
||||
/**
|
||||
* If the incoming item comes from an import, treat it as
|
||||
* less important than the existing one.
|
||||
*/
|
||||
item.payload.source === PayloadSource.FileImport ||
|
||||
/**
|
||||
* If the user is actively editing our item, duplicate the incoming item
|
||||
* to avoid creating surprises in the client's UI.
|
||||
*/
|
||||
Date.now() - this.userModifiedDate.getTime() < twentySeconds
|
||||
) {
|
||||
return ConflictStrategy.KeepBaseDuplicateApply
|
||||
} else {
|
||||
return ConflictStrategy.DuplicateBaseKeepApply
|
||||
}
|
||||
} else {
|
||||
/** Only the references have changed; merge them. */
|
||||
return ConflictStrategy.KeepBaseMergeRefs
|
||||
}
|
||||
}
|
||||
|
||||
public satisfiesPredicate(predicate: PredicateInterface<ItemInterface>): boolean {
|
||||
return predicate.matchesItem(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { AppDataField } from '../Types/AppDataField'
|
||||
import { ComponentDataDomain, DefaultAppDomain } from '../Types/DefaultAppDomain'
|
||||
import { ContentReference } from '../../Reference/ContentReference'
|
||||
import { PrefKey } from '../../../Syncable/UserPrefs/PrefKey'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { DecryptedPayloadInterface } from '../../Payload/Interfaces/DecryptedPayload'
|
||||
import { ItemInterface } from './ItemInterface'
|
||||
import { SortableItem } from '../../../Runtime/Collection/CollectionSort'
|
||||
import { DecryptedTransferPayload } from '../../TransferPayload/Interfaces/DecryptedTransferPayload'
|
||||
import { SearchableItem } from '../../../Runtime/Display'
|
||||
|
||||
export interface DecryptedItemInterface<C extends ItemContent = ItemContent>
|
||||
extends ItemInterface<DecryptedPayloadInterface<C>>,
|
||||
SortableItem,
|
||||
SearchableItem {
|
||||
readonly content: C
|
||||
readonly conflictOf?: Uuid
|
||||
readonly duplicateOf?: Uuid
|
||||
readonly protected: boolean
|
||||
readonly trashed: boolean
|
||||
readonly pinned: boolean
|
||||
readonly archived: boolean
|
||||
readonly locked: boolean
|
||||
readonly userModifiedDate: Date
|
||||
readonly references: ContentReference[]
|
||||
|
||||
getAppDomainValueWithDefault<T, D extends T>(key: AppDataField | PrefKey, defaultValue: D): T
|
||||
|
||||
getAppDomainValue<T>(key: AppDataField | PrefKey): T | undefined
|
||||
|
||||
isItemContentEqualWith(otherItem: DecryptedItemInterface): boolean
|
||||
|
||||
payloadRepresentation(override?: Partial<DecryptedTransferPayload<C>>): DecryptedPayloadInterface<C>
|
||||
|
||||
isReferencingItem(item: DecryptedItemInterface): boolean
|
||||
|
||||
getDomainData(domain: typeof ComponentDataDomain | typeof DefaultAppDomain): undefined | Record<string, unknown>
|
||||
|
||||
contentKeysToIgnoreWhenCheckingEquality<C extends ItemContent = ItemContent>(): (keyof C)[]
|
||||
|
||||
appDataContentKeysToIgnoreWhenCheckingEquality(): AppDataField[]
|
||||
|
||||
getContentCopy(): C
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DeletedPayloadInterface } from './../../Payload/Interfaces/DeletedPayload'
|
||||
import { ItemInterface } from './ItemInterface'
|
||||
|
||||
export interface DeletedItemInterface extends ItemInterface<DeletedPayloadInterface> {
|
||||
readonly deleted: true
|
||||
readonly content: undefined
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { EncryptedPayloadInterface } from '../../Payload/Interfaces/EncryptedPayload'
|
||||
import { ItemInterface } from './ItemInterface'
|
||||
|
||||
export interface EncryptedItemInterface extends ItemInterface<EncryptedPayloadInterface> {
|
||||
content: string
|
||||
version: ProtocolVersion
|
||||
errorDecrypting: boolean
|
||||
waitingForKey?: boolean
|
||||
auth_hash?: string
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Uuid, ContentType } from '@standardnotes/common'
|
||||
import { TransferPayload } from './../../TransferPayload/Interfaces/TransferPayload'
|
||||
import { PayloadInterface } from '../../Payload/Interfaces/PayloadInterface'
|
||||
import { PredicateInterface } from '../../../Runtime/Predicate/Interface'
|
||||
import { HistoryEntryInterface } from '../../../Runtime/History'
|
||||
import { ConflictStrategy } from '../Types/ConflictStrategy'
|
||||
import { SingletonStrategy } from '../Types/SingletonStrategy'
|
||||
|
||||
export interface ItemInterface<P extends PayloadInterface = PayloadInterface> {
|
||||
payload: P
|
||||
readonly conflictOf?: Uuid
|
||||
readonly duplicateOf?: Uuid
|
||||
readonly createdAtString?: string
|
||||
readonly updatedAtString?: string
|
||||
|
||||
uuid: Uuid
|
||||
|
||||
content_type: ContentType
|
||||
created_at: Date
|
||||
serverUpdatedAt: Date
|
||||
serverUpdatedAtTimestamp: number | undefined
|
||||
dirty: boolean | undefined
|
||||
|
||||
lastSyncBegan: Date | undefined
|
||||
lastSyncEnd: Date | undefined
|
||||
neverSynced: boolean
|
||||
|
||||
duplicate_of: string | undefined
|
||||
isSingleton: boolean
|
||||
updated_at: Date | undefined
|
||||
|
||||
singletonPredicate<T extends ItemInterface>(): PredicateInterface<T>
|
||||
|
||||
singletonStrategy: SingletonStrategy
|
||||
|
||||
strategyWhenConflictingWithItem(item: ItemInterface, previousRevision?: HistoryEntryInterface): ConflictStrategy
|
||||
|
||||
satisfiesPredicate(predicate: PredicateInterface<ItemInterface>): boolean
|
||||
|
||||
payloadRepresentation(override?: Partial<TransferPayload>): P
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EncryptedItemInterface } from './EncryptedItem'
|
||||
import { DeletedItemInterface } from './DeletedItem'
|
||||
import { ItemInterface } from './ItemInterface'
|
||||
import { DecryptedItemInterface } from './DecryptedItem'
|
||||
import { isDecryptedPayload, isDeletedPayload, isEncryptedPayload } from '../../Payload/Interfaces/TypeCheck'
|
||||
|
||||
export function isDecryptedItem(item: ItemInterface): item is DecryptedItemInterface {
|
||||
return isDecryptedPayload(item.payload)
|
||||
}
|
||||
|
||||
export function isEncryptedItem(item: ItemInterface): item is EncryptedItemInterface {
|
||||
return isEncryptedPayload(item.payload)
|
||||
}
|
||||
|
||||
export function isNotEncryptedItem(
|
||||
item: DecryptedItemInterface | DeletedItemInterface | EncryptedItemInterface,
|
||||
): item is DecryptedItemInterface | DeletedItemInterface {
|
||||
return !isEncryptedItem(item)
|
||||
}
|
||||
|
||||
export function isDeletedItem(item: ItemInterface): item is DeletedItemInterface {
|
||||
return isDeletedPayload(item.payload)
|
||||
}
|
||||
|
||||
export function isDecryptedOrDeletedItem(item: ItemInterface): item is DecryptedItemInterface | DeletedItemInterface {
|
||||
return isDecryptedItem(item) || isDeletedItem(item)
|
||||
}
|
||||
|
||||
export function isEncryptedErroredItem(item: ItemInterface): boolean {
|
||||
return isEncryptedItem(item) && item.errorDecrypting === true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { DecryptedItemInterface } from './DecryptedItem'
|
||||
import { DeletedItemInterface } from './DeletedItem'
|
||||
import { EncryptedItemInterface } from './EncryptedItem'
|
||||
|
||||
export type AnyItemInterface<C extends ItemContent = ItemContent> =
|
||||
| EncryptedItemInterface
|
||||
| DecryptedItemInterface<C>
|
||||
| DeletedItemInterface
|
||||
@@ -0,0 +1,145 @@
|
||||
import { DecryptedItemInterface } from './../Interfaces/DecryptedItem'
|
||||
import { Copy } from '@standardnotes/utils'
|
||||
import { MutationType } from '../Types/MutationType'
|
||||
import { PrefKey } from '../../../Syncable/UserPrefs/PrefKey'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { AppDataField } from '../Types/AppDataField'
|
||||
import { DefaultAppDomain, DomainDataValueType, ItemDomainKey } from '../Types/DefaultAppDomain'
|
||||
import { ItemMutator } from './ItemMutator'
|
||||
import { DecryptedPayloadInterface } from '../../Payload/Interfaces/DecryptedPayload'
|
||||
import { ItemInterface } from '../Interfaces/ItemInterface'
|
||||
import { getIncrementedDirtyIndex } from '../../../Runtime/DirtyCounter/DirtyCounter'
|
||||
|
||||
export class DecryptedItemMutator<C extends ItemContent = ItemContent> extends ItemMutator<
|
||||
DecryptedPayloadInterface<C>,
|
||||
DecryptedItemInterface<C>
|
||||
> {
|
||||
protected mutableContent: C
|
||||
|
||||
constructor(item: DecryptedItemInterface<C>, type: MutationType) {
|
||||
super(item, type)
|
||||
|
||||
const mutableCopy = Copy(this.immutablePayload.content)
|
||||
this.mutableContent = mutableCopy
|
||||
}
|
||||
|
||||
public override getResult() {
|
||||
if (this.type === MutationType.NonDirtying) {
|
||||
return this.immutablePayload.copy({
|
||||
content: this.mutableContent,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.type === MutationType.UpdateUserTimestamps) {
|
||||
this.userModifiedDate = new Date()
|
||||
} else {
|
||||
const currentValue = this.immutableItem.userModifiedDate
|
||||
if (!currentValue) {
|
||||
this.userModifiedDate = new Date(this.immutableItem.serverUpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.immutablePayload.copy({
|
||||
content: this.mutableContent,
|
||||
dirty: true,
|
||||
dirtyIndex: getIncrementedDirtyIndex(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public override setBeginSync(began: Date, globalDirtyIndex: number) {
|
||||
this.immutablePayload = this.immutablePayload.copy({
|
||||
content: this.mutableContent,
|
||||
lastSyncBegan: began,
|
||||
globalDirtyIndexAtLastSync: globalDirtyIndex,
|
||||
})
|
||||
}
|
||||
|
||||
/** Not recommended to use as this might break item schema if used incorrectly */
|
||||
public setCustomContent(content: C): void {
|
||||
this.mutableContent = Copy(content)
|
||||
}
|
||||
|
||||
public set userModifiedDate(date: Date) {
|
||||
this.setAppDataItem(AppDataField.UserModifiedDate, date)
|
||||
}
|
||||
|
||||
public set conflictOf(conflictOf: Uuid | undefined) {
|
||||
this.mutableContent.conflict_of = conflictOf
|
||||
}
|
||||
|
||||
public set protected(isProtected: boolean) {
|
||||
this.mutableContent.protected = isProtected
|
||||
}
|
||||
|
||||
public set trashed(trashed: boolean) {
|
||||
this.mutableContent.trashed = trashed
|
||||
}
|
||||
|
||||
public set pinned(pinned: boolean) {
|
||||
this.setAppDataItem(AppDataField.Pinned, pinned)
|
||||
}
|
||||
|
||||
public set archived(archived: boolean) {
|
||||
this.setAppDataItem(AppDataField.Archived, archived)
|
||||
}
|
||||
|
||||
public set locked(locked: boolean) {
|
||||
this.setAppDataItem(AppDataField.Locked, locked)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the entirety of this domain's data with the data arg.
|
||||
*/
|
||||
public setDomainData(data: DomainDataValueType, domain: ItemDomainKey): void {
|
||||
if (!this.mutableContent.appData) {
|
||||
this.mutableContent.appData = {
|
||||
[DefaultAppDomain]: {},
|
||||
}
|
||||
}
|
||||
|
||||
this.mutableContent.appData[domain] = data
|
||||
}
|
||||
|
||||
/**
|
||||
* First gets the domain data for the input domain.
|
||||
* Then sets data[key] = value
|
||||
*/
|
||||
public setDomainDataKey(key: keyof DomainDataValueType, value: unknown, domain: ItemDomainKey): void {
|
||||
if (!this.mutableContent.appData) {
|
||||
this.mutableContent.appData = {
|
||||
[DefaultAppDomain]: {},
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.mutableContent.appData[domain]) {
|
||||
this.mutableContent.appData[domain] = {}
|
||||
}
|
||||
|
||||
const domainData = this.mutableContent.appData[domain] as DomainDataValueType
|
||||
domainData[key] = value
|
||||
}
|
||||
|
||||
public setAppDataItem(key: AppDataField | PrefKey, value: unknown) {
|
||||
this.setDomainDataKey(key, value, DefaultAppDomain)
|
||||
}
|
||||
|
||||
public e2ePendingRefactor_addItemAsRelationship(item: DecryptedItemInterface) {
|
||||
const references = this.mutableContent.references || []
|
||||
if (!references.find((r) => r.uuid === item.uuid)) {
|
||||
references.push({
|
||||
uuid: item.uuid,
|
||||
content_type: item.content_type,
|
||||
})
|
||||
}
|
||||
this.mutableContent.references = references
|
||||
}
|
||||
|
||||
public removeItemAsRelationship(item: ItemInterface) {
|
||||
let references = this.mutableContent.references || []
|
||||
references = references.filter((r) => r.uuid !== item.uuid)
|
||||
this.mutableContent.references = references
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DeletedPayload } from './../../Payload/Implementations/DeletedPayload'
|
||||
import { DeletedPayloadInterface, PayloadInterface } from '../../Payload'
|
||||
import { ItemInterface } from '../Interfaces/ItemInterface'
|
||||
import { ItemMutator } from './ItemMutator'
|
||||
import { MutationType } from '../Types/MutationType'
|
||||
import { getIncrementedDirtyIndex } from '../../../Runtime/DirtyCounter/DirtyCounter'
|
||||
|
||||
export class DeleteItemMutator<
|
||||
I extends ItemInterface<PayloadInterface> = ItemInterface<PayloadInterface>,
|
||||
> extends ItemMutator<PayloadInterface, I> {
|
||||
public getDeletedResult(): DeletedPayloadInterface {
|
||||
const dirtying = this.type !== MutationType.NonDirtying
|
||||
const result = new DeletedPayload(
|
||||
{
|
||||
...this.immutablePayload.ejected(),
|
||||
deleted: true,
|
||||
content: undefined,
|
||||
dirty: dirtying ? true : this.immutablePayload.dirty,
|
||||
dirtyIndex: dirtying ? getIncrementedDirtyIndex() : this.immutablePayload.dirtyIndex,
|
||||
},
|
||||
this.immutablePayload.source,
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public override getResult(): PayloadInterface {
|
||||
throw Error('Must use getDeletedResult')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MutationType } from '../Types/MutationType'
|
||||
import { PayloadInterface } from '../../Payload'
|
||||
import { ItemInterface } from '../Interfaces/ItemInterface'
|
||||
import { TransferPayload } from '../../TransferPayload'
|
||||
import { getIncrementedDirtyIndex } from '../../../Runtime/DirtyCounter/DirtyCounter'
|
||||
|
||||
/**
|
||||
* An item mutator takes in an item, and an operation, and returns the resulting payload.
|
||||
* Subclasses of mutators can modify the content field directly, but cannot modify the payload directly.
|
||||
* All changes to the payload must occur by copying the payload and reassigning its value.
|
||||
*/
|
||||
export class ItemMutator<
|
||||
P extends PayloadInterface<TransferPayload> = PayloadInterface<TransferPayload>,
|
||||
I extends ItemInterface<P> = ItemInterface<P>,
|
||||
> {
|
||||
public readonly immutableItem: I
|
||||
protected immutablePayload: P
|
||||
protected readonly type: MutationType
|
||||
|
||||
constructor(item: I, type: MutationType) {
|
||||
this.immutableItem = item
|
||||
this.type = type
|
||||
this.immutablePayload = item.payload
|
||||
}
|
||||
|
||||
public getUuid() {
|
||||
return this.immutablePayload.uuid
|
||||
}
|
||||
|
||||
public getItem(): I {
|
||||
return this.immutableItem
|
||||
}
|
||||
|
||||
public getResult(): P {
|
||||
if (this.type === MutationType.NonDirtying) {
|
||||
return this.immutablePayload.copy()
|
||||
}
|
||||
|
||||
const result = this.immutablePayload.copy({
|
||||
dirty: true,
|
||||
dirtyIndex: getIncrementedDirtyIndex(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public setBeginSync(began: Date, globalDirtyIndex: number) {
|
||||
this.immutablePayload = this.immutablePayload.copy({
|
||||
lastSyncBegan: began,
|
||||
globalDirtyIndexAtLastSync: globalDirtyIndex,
|
||||
})
|
||||
}
|
||||
|
||||
public set errorDecrypting(_: boolean) {
|
||||
throw Error('This method is no longer implemented')
|
||||
}
|
||||
|
||||
public set updated_at(_: Date) {
|
||||
throw Error('This method is no longer implemented')
|
||||
}
|
||||
|
||||
public set updated_at_timestamp(_: number) {
|
||||
throw Error('This method is no longer implemented')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export enum AppDataField {
|
||||
Pinned = 'pinned',
|
||||
Archived = 'archived',
|
||||
Locked = 'locked',
|
||||
UserModifiedDate = 'client_updated_at',
|
||||
DefaultEditor = 'defaultEditor',
|
||||
MobileRules = 'mobileRules',
|
||||
NotAvailableOnMobile = 'notAvailableOnMobile',
|
||||
MobileActive = 'mobileActive',
|
||||
LastSize = 'lastSize',
|
||||
PrefersPlainEditor = 'prefersPlainEditor',
|
||||
ComponentInstallError = 'installError',
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum ConflictStrategy {
|
||||
KeepBase = 1,
|
||||
KeepApply = 2,
|
||||
KeepBaseDuplicateApply = 3,
|
||||
DuplicateBaseKeepApply = 4,
|
||||
KeepBaseMergeRefs = 5,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrefKey } from '../../../Syncable/UserPrefs/PrefKey'
|
||||
import { AppDataField } from './AppDataField'
|
||||
|
||||
export const DefaultAppDomain = 'org.standardnotes.sn'
|
||||
/* This domain will be used to save context item client data */
|
||||
export const ComponentDataDomain = 'org.standardnotes.sn.components'
|
||||
|
||||
export type ItemDomainKey = typeof DefaultAppDomain | typeof ComponentDataDomain
|
||||
|
||||
export type AppDomainValueType = Partial<Record<AppDataField | PrefKey, unknown>>
|
||||
export type ComponentDomainValueType = Record<string, unknown>
|
||||
export type DomainDataValueType = AppDomainValueType | ComponentDomainValueType
|
||||
|
||||
export type AppData = {
|
||||
[DefaultAppDomain]: AppDomainValueType
|
||||
[ComponentDataDomain]?: ComponentDomainValueType
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export enum MutationType {
|
||||
UpdateUserTimestamps = 1,
|
||||
/**
|
||||
* The item was changed as part of an internal operation, such as a migration, or, a user
|
||||
* interaction that shouldn't modify timestamps (pinning, protecting, etc).
|
||||
*/
|
||||
NoUpdateUserTimestamps = 2,
|
||||
/**
|
||||
* The item was changed as part of an internal function that wishes to modify
|
||||
* internal item properties, such as lastSyncBegan, without modifying the item's dirty
|
||||
* state. By default all other mutation types will result in a dirtied result.
|
||||
*/
|
||||
NonDirtying = 3,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum SingletonStrategy {
|
||||
KeepEarliest = 1,
|
||||
}
|
||||
29
packages/models/src/Domain/Abstract/Item/index.ts
Normal file
29
packages/models/src/Domain/Abstract/Item/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export * from '../Reference/AnonymousReference'
|
||||
export * from '../Reference/ContenteReferenceType'
|
||||
export * from '../Reference/ContentReference'
|
||||
export * from '../Reference/FileToNoteReference'
|
||||
export * from '../Reference/Functions'
|
||||
export * from '../Reference/LegacyAnonymousReference'
|
||||
export * from '../Reference/LegacyTagToNoteReference'
|
||||
export * from '../Reference/Reference'
|
||||
export * from '../Reference/TagToParentTagReference'
|
||||
export * from './Implementations/DecryptedItem'
|
||||
export * from './Implementations/DecryptedItem'
|
||||
export * from './Implementations/DeletedItem'
|
||||
export * from './Implementations/EncryptedItem'
|
||||
export * from './Implementations/GenericItem'
|
||||
export * from './Interfaces/DecryptedItem'
|
||||
export * from './Interfaces/DeletedItem'
|
||||
export * from './Interfaces/EncryptedItem'
|
||||
export * from './Interfaces/ItemInterface'
|
||||
export * from './Interfaces/TypeCheck'
|
||||
export * from './Mutator/DecryptedItemMutator'
|
||||
export * from './Mutator/DeleteMutator'
|
||||
export * from './Mutator/ItemMutator'
|
||||
export * from './Types/AppDataField'
|
||||
export * from './Types/AppDataField'
|
||||
export * from './Types/ConflictStrategy'
|
||||
export * from './Types/DefaultAppDomain'
|
||||
export * from './Types/DefaultAppDomain'
|
||||
export * from './Types/MutationType'
|
||||
export * from './Types/SingletonStrategy'
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { Copy } from '@standardnotes/utils'
|
||||
import { SyncResolvedParams, SyncResolvedPayload } from '../../../Runtime/Deltas/Utilities/SyncResolvedPayload'
|
||||
import { FillItemContent, ItemContent } from '../../Content/ItemContent'
|
||||
import { ContentReference } from '../../Reference/ContentReference'
|
||||
import { DecryptedTransferPayload } from '../../TransferPayload/Interfaces/DecryptedTransferPayload'
|
||||
import { DecryptedPayloadInterface } from '../Interfaces/DecryptedPayload'
|
||||
import { PayloadSource } from '../Types/PayloadSource'
|
||||
import { PurePayload } from './PurePayload'
|
||||
|
||||
export class DecryptedPayload<
|
||||
C extends ItemContent = ItemContent,
|
||||
T extends DecryptedTransferPayload<C> = DecryptedTransferPayload<C>,
|
||||
>
|
||||
extends PurePayload<T>
|
||||
implements DecryptedPayloadInterface<C>
|
||||
{
|
||||
override readonly content: C
|
||||
override readonly deleted: false
|
||||
|
||||
constructor(rawPayload: T, source = PayloadSource.Constructor) {
|
||||
super(rawPayload, source)
|
||||
|
||||
this.content = Copy(FillItemContent<C>(rawPayload.content))
|
||||
this.deleted = false
|
||||
}
|
||||
|
||||
get references(): ContentReference[] {
|
||||
return this.content.references || []
|
||||
}
|
||||
|
||||
public getReference(uuid: Uuid): ContentReference {
|
||||
const result = this.references.find((ref) => ref.uuid === uuid)
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Reference not found')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override ejected(): DecryptedTransferPayload<C> {
|
||||
return {
|
||||
...super.ejected(),
|
||||
content: this.content,
|
||||
deleted: this.deleted,
|
||||
}
|
||||
}
|
||||
|
||||
copy(override?: Partial<T>, source = this.source): this {
|
||||
const result = new DecryptedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as this
|
||||
}
|
||||
|
||||
copyAsSyncResolved(override?: Partial<T> & SyncResolvedParams, source = this.source): SyncResolvedPayload {
|
||||
const result = new DecryptedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as SyncResolvedPayload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DeletedTransferPayload } from './../../TransferPayload/Interfaces/DeletedTransferPayload'
|
||||
import { DeletedPayloadInterface } from '../Interfaces/DeletedPayload'
|
||||
import { PayloadSource } from '../Types/PayloadSource'
|
||||
import { PurePayload } from './PurePayload'
|
||||
import { SyncResolvedParams, SyncResolvedPayload } from '../../../Runtime/Deltas/Utilities/SyncResolvedPayload'
|
||||
|
||||
export class DeletedPayload extends PurePayload<DeletedTransferPayload> implements DeletedPayloadInterface {
|
||||
override readonly deleted: true
|
||||
override readonly content: undefined
|
||||
|
||||
constructor(rawPayload: DeletedTransferPayload, source = PayloadSource.Constructor) {
|
||||
super(rawPayload, source)
|
||||
|
||||
this.deleted = true
|
||||
this.content = undefined
|
||||
}
|
||||
|
||||
get discardable(): boolean | undefined {
|
||||
return !this.dirty
|
||||
}
|
||||
|
||||
override ejected(): DeletedTransferPayload {
|
||||
return {
|
||||
...super.ejected(),
|
||||
deleted: this.deleted,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
copy(override?: Partial<DeletedTransferPayload>, source = this.source): this {
|
||||
const result = new DeletedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as this
|
||||
}
|
||||
|
||||
copyAsSyncResolved(
|
||||
override?: Partial<DeletedTransferPayload> & SyncResolvedParams,
|
||||
source = this.source,
|
||||
): SyncResolvedPayload {
|
||||
const result = new DeletedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as SyncResolvedPayload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ProtocolVersion, protocolVersionFromEncryptedString } from '@standardnotes/common'
|
||||
import { SyncResolvedParams, SyncResolvedPayload } from '../../../Runtime/Deltas/Utilities/SyncResolvedPayload'
|
||||
import { EncryptedTransferPayload } from '../../TransferPayload/Interfaces/EncryptedTransferPayload'
|
||||
import { EncryptedPayloadInterface } from '../Interfaces/EncryptedPayload'
|
||||
import { PayloadSource } from '../Types/PayloadSource'
|
||||
import { PurePayload } from './PurePayload'
|
||||
|
||||
export class EncryptedPayload extends PurePayload<EncryptedTransferPayload> implements EncryptedPayloadInterface {
|
||||
override readonly content: string
|
||||
override readonly deleted: false
|
||||
readonly auth_hash?: string
|
||||
readonly enc_item_key: string
|
||||
readonly errorDecrypting: boolean
|
||||
readonly items_key_id: string | undefined
|
||||
readonly version: ProtocolVersion
|
||||
readonly waitingForKey: boolean
|
||||
|
||||
constructor(rawPayload: EncryptedTransferPayload, source = PayloadSource.Constructor) {
|
||||
super(rawPayload, source)
|
||||
|
||||
this.auth_hash = rawPayload.auth_hash
|
||||
this.content = rawPayload.content
|
||||
this.deleted = false
|
||||
this.enc_item_key = rawPayload.enc_item_key
|
||||
this.errorDecrypting = rawPayload.errorDecrypting
|
||||
this.items_key_id = rawPayload.items_key_id
|
||||
this.version = protocolVersionFromEncryptedString(this.content)
|
||||
this.waitingForKey = rawPayload.waitingForKey
|
||||
}
|
||||
|
||||
override ejected(): EncryptedTransferPayload {
|
||||
return {
|
||||
...super.ejected(),
|
||||
enc_item_key: this.enc_item_key,
|
||||
items_key_id: this.items_key_id,
|
||||
auth_hash: this.auth_hash,
|
||||
errorDecrypting: this.errorDecrypting,
|
||||
waitingForKey: this.waitingForKey,
|
||||
content: this.content,
|
||||
deleted: this.deleted,
|
||||
}
|
||||
}
|
||||
|
||||
copy(override?: Partial<EncryptedTransferPayload>, source = this.source): this {
|
||||
const result = new EncryptedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as this
|
||||
}
|
||||
|
||||
copyAsSyncResolved(
|
||||
override?: Partial<EncryptedTransferPayload> & SyncResolvedParams,
|
||||
source = this.source,
|
||||
): SyncResolvedPayload {
|
||||
const result = new EncryptedPayload(
|
||||
{
|
||||
...this.ejected(),
|
||||
...override,
|
||||
},
|
||||
source,
|
||||
)
|
||||
return result as SyncResolvedPayload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { deepFreeze, useBoolean } from '@standardnotes/utils'
|
||||
import { PayloadInterface } from '../Interfaces/PayloadInterface'
|
||||
import { PayloadSource } from '../Types/PayloadSource'
|
||||
import { TransferPayload } from '../../TransferPayload/Interfaces/TransferPayload'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { SyncResolvedParams, SyncResolvedPayload } from '../../../Runtime/Deltas/Utilities/SyncResolvedPayload'
|
||||
|
||||
type RequiredKeepUndefined<T> = { [K in keyof T]-?: [T[K]] } extends infer U
|
||||
? U extends Record<keyof U, [unknown]>
|
||||
? { [K in keyof U]: U[K][0] }
|
||||
: never
|
||||
: never
|
||||
|
||||
export abstract class PurePayload<T extends TransferPayload<C>, C extends ItemContent = ItemContent>
|
||||
implements PayloadInterface<T>
|
||||
{
|
||||
readonly source: PayloadSource
|
||||
readonly uuid: string
|
||||
readonly content_type: ContentType
|
||||
readonly deleted: boolean
|
||||
readonly content: C | string | undefined
|
||||
|
||||
readonly created_at: Date
|
||||
readonly updated_at: Date
|
||||
readonly created_at_timestamp: number
|
||||
readonly updated_at_timestamp: number
|
||||
readonly dirtyIndex?: number
|
||||
readonly globalDirtyIndexAtLastSync?: number
|
||||
readonly dirty?: boolean
|
||||
|
||||
readonly lastSyncBegan?: Date
|
||||
readonly lastSyncEnd?: Date
|
||||
|
||||
readonly duplicate_of?: string
|
||||
|
||||
constructor(rawPayload: T, source = PayloadSource.Constructor) {
|
||||
this.source = source
|
||||
this.uuid = rawPayload.uuid
|
||||
|
||||
if (!this.uuid) {
|
||||
throw Error(
|
||||
`Attempting to construct payload with null uuid
|
||||
Content type: ${rawPayload.content_type}`,
|
||||
)
|
||||
}
|
||||
|
||||
this.content = rawPayload.content
|
||||
this.content_type = rawPayload.content_type
|
||||
this.deleted = useBoolean(rawPayload.deleted, false)
|
||||
this.dirty = rawPayload.dirty
|
||||
this.duplicate_of = rawPayload.duplicate_of
|
||||
|
||||
this.created_at = new Date(rawPayload.created_at || new Date())
|
||||
this.updated_at = new Date(rawPayload.updated_at || 0)
|
||||
|
||||
this.created_at_timestamp = rawPayload.created_at_timestamp || 0
|
||||
this.updated_at_timestamp = rawPayload.updated_at_timestamp || 0
|
||||
|
||||
this.lastSyncBegan = rawPayload.lastSyncBegan ? new Date(rawPayload.lastSyncBegan) : undefined
|
||||
this.lastSyncEnd = rawPayload.lastSyncEnd ? new Date(rawPayload.lastSyncEnd) : undefined
|
||||
|
||||
this.dirtyIndex = rawPayload.dirtyIndex
|
||||
this.globalDirtyIndexAtLastSync = rawPayload.globalDirtyIndexAtLastSync
|
||||
|
||||
const timeToAllowSubclassesToFinishConstruction = 0
|
||||
setTimeout(() => {
|
||||
deepFreeze(this)
|
||||
}, timeToAllowSubclassesToFinishConstruction)
|
||||
}
|
||||
|
||||
ejected(): TransferPayload {
|
||||
const comprehensive: RequiredKeepUndefined<TransferPayload> = {
|
||||
uuid: this.uuid,
|
||||
content: this.content,
|
||||
deleted: this.deleted,
|
||||
content_type: this.content_type,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
created_at_timestamp: this.created_at_timestamp,
|
||||
updated_at_timestamp: this.updated_at_timestamp,
|
||||
dirty: this.dirty,
|
||||
duplicate_of: this.duplicate_of,
|
||||
dirtyIndex: this.dirtyIndex,
|
||||
globalDirtyIndexAtLastSync: this.globalDirtyIndexAtLastSync,
|
||||
lastSyncBegan: this.lastSyncBegan,
|
||||
lastSyncEnd: this.lastSyncEnd,
|
||||
}
|
||||
|
||||
return comprehensive
|
||||
}
|
||||
|
||||
public get serverUpdatedAt(): Date {
|
||||
return this.updated_at
|
||||
}
|
||||
|
||||
public get serverUpdatedAtTimestamp(): number {
|
||||
return this.updated_at_timestamp
|
||||
}
|
||||
|
||||
abstract copy(override?: Partial<TransferPayload>, source?: PayloadSource): this
|
||||
|
||||
abstract copyAsSyncResolved(override?: Partial<T> & SyncResolvedParams, source?: PayloadSource): SyncResolvedPayload
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { DecryptedTransferPayload } from './../../TransferPayload/Interfaces/DecryptedTransferPayload'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { ContentReference } from '../../Reference/ContentReference'
|
||||
import { PayloadInterface } from './PayloadInterface'
|
||||
|
||||
export interface DecryptedPayloadInterface<C extends ItemContent = ItemContent>
|
||||
extends PayloadInterface<DecryptedTransferPayload> {
|
||||
readonly content: C
|
||||
deleted: false
|
||||
|
||||
ejected(): DecryptedTransferPayload<C>
|
||||
get references(): ContentReference[]
|
||||
getReference(uuid: Uuid): ContentReference
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DeletedTransferPayload } from '../../TransferPayload'
|
||||
import { PayloadInterface } from './PayloadInterface'
|
||||
|
||||
export interface DeletedPayloadInterface extends PayloadInterface<DeletedTransferPayload> {
|
||||
readonly deleted: true
|
||||
readonly content: undefined
|
||||
|
||||
/**
|
||||
* Whether a payload can be discarded and removed from storage.
|
||||
* This value is true if a payload is marked as deleted and not dirty.
|
||||
*/
|
||||
discardable: boolean | undefined
|
||||
|
||||
ejected(): DeletedTransferPayload
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { EncryptedTransferPayload } from '../../TransferPayload/Interfaces/EncryptedTransferPayload'
|
||||
import { PayloadInterface } from './PayloadInterface'
|
||||
|
||||
export interface EncryptedPayloadInterface extends PayloadInterface<EncryptedTransferPayload> {
|
||||
readonly content: string
|
||||
readonly deleted: false
|
||||
readonly enc_item_key: string
|
||||
readonly items_key_id: string | undefined
|
||||
readonly errorDecrypting: boolean
|
||||
readonly waitingForKey: boolean
|
||||
readonly version: ProtocolVersion
|
||||
|
||||
/** @deprecated */
|
||||
readonly auth_hash?: string
|
||||
|
||||
ejected(): EncryptedTransferPayload
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SyncResolvedParams, SyncResolvedPayload } from './../../../Runtime/Deltas/Utilities/SyncResolvedPayload'
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { TransferPayload } from '../../TransferPayload/Interfaces/TransferPayload'
|
||||
import { PayloadSource } from '../Types/PayloadSource'
|
||||
|
||||
export interface PayloadInterface<T extends TransferPayload = TransferPayload, C extends ItemContent = ItemContent> {
|
||||
readonly source: PayloadSource
|
||||
readonly uuid: Uuid
|
||||
readonly content_type: ContentType
|
||||
content: C | string | undefined
|
||||
deleted: boolean
|
||||
|
||||
/** updated_at is set by the server only, and not the client.*/
|
||||
readonly updated_at: Date
|
||||
readonly created_at: Date
|
||||
readonly created_at_timestamp: number
|
||||
readonly updated_at_timestamp: number
|
||||
get serverUpdatedAt(): Date
|
||||
get serverUpdatedAtTimestamp(): number
|
||||
|
||||
readonly dirtyIndex?: number
|
||||
readonly globalDirtyIndexAtLastSync?: number
|
||||
readonly dirty?: boolean
|
||||
|
||||
readonly lastSyncBegan?: Date
|
||||
readonly lastSyncEnd?: Date
|
||||
|
||||
readonly duplicate_of?: Uuid
|
||||
|
||||
/**
|
||||
* "Ejected" means a payload for
|
||||
* generic, non-contextual consumption, such as saving to a backup file or syncing
|
||||
* with a server.
|
||||
*/
|
||||
ejected(): TransferPayload
|
||||
|
||||
copy(override?: Partial<T>, source?: PayloadSource): this
|
||||
|
||||
copyAsSyncResolved(override?: Partial<T> & SyncResolvedParams, source?: PayloadSource): SyncResolvedPayload
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import {
|
||||
isDecryptedTransferPayload,
|
||||
isDeletedTransferPayload,
|
||||
isEncryptedTransferPayload,
|
||||
isErrorDecryptingTransferPayload,
|
||||
} from '../../TransferPayload'
|
||||
import { DecryptedPayloadInterface } from './DecryptedPayload'
|
||||
import { DeletedPayloadInterface } from './DeletedPayload'
|
||||
import { EncryptedPayloadInterface } from './EncryptedPayload'
|
||||
import { PayloadInterface } from './PayloadInterface'
|
||||
|
||||
export function isDecryptedPayload<C extends ItemContent = ItemContent>(
|
||||
payload: PayloadInterface,
|
||||
): payload is DecryptedPayloadInterface<C> {
|
||||
return isDecryptedTransferPayload(payload)
|
||||
}
|
||||
|
||||
export function isEncryptedPayload(payload: PayloadInterface): payload is EncryptedPayloadInterface {
|
||||
return isEncryptedTransferPayload(payload)
|
||||
}
|
||||
|
||||
export function isDeletedPayload(payload: PayloadInterface): payload is DeletedPayloadInterface {
|
||||
return isDeletedTransferPayload(payload)
|
||||
}
|
||||
|
||||
export function isErrorDecryptingPayload(payload: PayloadInterface): payload is EncryptedPayloadInterface {
|
||||
return isErrorDecryptingTransferPayload(payload)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { DecryptedPayloadInterface } from './DecryptedPayload'
|
||||
import { DeletedPayloadInterface } from './DeletedPayload'
|
||||
import { EncryptedPayloadInterface } from './EncryptedPayload'
|
||||
|
||||
export type FullyFormedPayloadInterface<C extends ItemContent = ItemContent> =
|
||||
| DecryptedPayloadInterface<C>
|
||||
| EncryptedPayloadInterface
|
||||
| DeletedPayloadInterface
|
||||
|
||||
export type AnyNonDecryptedPayloadInterface = EncryptedPayloadInterface | DeletedPayloadInterface
|
||||
@@ -0,0 +1,43 @@
|
||||
export enum PayloadEmitSource {
|
||||
/** When an observer registers to stream items, the items are pushed immediately to the observer */
|
||||
InitialObserverRegistrationPush = 1,
|
||||
|
||||
/**
|
||||
* Payload when a client modifies item property then maps it to update UI.
|
||||
* This also indicates that the item was dirtied
|
||||
*/
|
||||
LocalChanged,
|
||||
LocalInserted,
|
||||
LocalDatabaseLoaded,
|
||||
/** The payload returned by offline sync operation */
|
||||
OfflineSyncSaved,
|
||||
LocalRetrieved,
|
||||
|
||||
FileImport,
|
||||
|
||||
ComponentRetrieved,
|
||||
/** Payloads received from an external component with the intention of creating a new item */
|
||||
ComponentCreated,
|
||||
|
||||
/**
|
||||
* When the payloads are about to sync, they are emitted by the sync service with updated
|
||||
* values of lastSyncBegan. Payloads emitted from this source indicate that these payloads
|
||||
* have been saved to disk, and are about to be synced
|
||||
*/
|
||||
PreSyncSave,
|
||||
|
||||
RemoteRetrieved,
|
||||
RemoteSaved,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the changed payload represents only an internal change that shouldn't
|
||||
* require a UI refresh
|
||||
*/
|
||||
export function isPayloadSourceInternalChange(source: PayloadEmitSource): boolean {
|
||||
return [PayloadEmitSource.RemoteSaved, PayloadEmitSource.PreSyncSave].includes(source)
|
||||
}
|
||||
|
||||
export function isPayloadSourceRetrieved(source: PayloadEmitSource): boolean {
|
||||
return [PayloadEmitSource.RemoteRetrieved, PayloadEmitSource.ComponentRetrieved].includes(source)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export enum PayloadSource {
|
||||
/**
|
||||
* Payloads with a source of Constructor means that the payload was created
|
||||
* in isolated space by the caller, and does not yet have any app-related affiliation.
|
||||
*/
|
||||
Constructor = 1,
|
||||
|
||||
RemoteRetrieved,
|
||||
|
||||
RemoteSaved,
|
||||
|
||||
FileImport,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function PayloadTimestampDefaults() {
|
||||
return {
|
||||
updated_at: new Date(0),
|
||||
created_at: new Date(),
|
||||
updated_at_timestamp: 0,
|
||||
created_at_timestamp: 0,
|
||||
}
|
||||
}
|
||||
13
packages/models/src/Domain/Abstract/Payload/index.ts
Normal file
13
packages/models/src/Domain/Abstract/Payload/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from './Implementations/PurePayload'
|
||||
export * from './Implementations/DecryptedPayload'
|
||||
export * from './Implementations/EncryptedPayload'
|
||||
export * from './Implementations/DeletedPayload'
|
||||
export * from './Interfaces/DecryptedPayload'
|
||||
export * from './Interfaces/DeletedPayload'
|
||||
export * from './Interfaces/EncryptedPayload'
|
||||
export * from './Interfaces/PayloadInterface'
|
||||
export * from './Interfaces/TypeCheck'
|
||||
export * from './Interfaces/UnionTypes'
|
||||
export * from './Types/PayloadSource'
|
||||
export * from './Types/EmitSource'
|
||||
export * from './Types/TimestampDefaults'
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ContenteReferenceType } from './ContenteReferenceType'
|
||||
|
||||
export interface AnonymousReference {
|
||||
uuid: string
|
||||
content_type: ContentType
|
||||
reference_type: ContenteReferenceType
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { LegacyAnonymousReference } from './LegacyAnonymousReference'
|
||||
import { Reference } from './Reference'
|
||||
|
||||
export type ContentReference = LegacyAnonymousReference | Reference
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ContenteReferenceType {
|
||||
TagToParentTag = 'TagToParentTag',
|
||||
FileToNote = 'FileToNote',
|
||||
TagToFile = 'TagToFile',
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { AnonymousReference } from './AnonymousReference'
|
||||
import { ContenteReferenceType } from './ContenteReferenceType'
|
||||
|
||||
export interface FileToNoteReference extends AnonymousReference {
|
||||
content_type: ContentType.Note
|
||||
reference_type: ContenteReferenceType.FileToNote
|
||||
}
|
||||
30
packages/models/src/Domain/Abstract/Reference/Functions.ts
Normal file
30
packages/models/src/Domain/Abstract/Reference/Functions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ItemInterface } from '../Item/Interfaces/ItemInterface'
|
||||
import { ContenteReferenceType } from './ContenteReferenceType'
|
||||
import { ContentReference } from './ContentReference'
|
||||
import { LegacyAnonymousReference } from './LegacyAnonymousReference'
|
||||
import { LegacyTagToNoteReference } from './LegacyTagToNoteReference'
|
||||
import { Reference } from './Reference'
|
||||
import { TagToParentTagReference } from './TagToParentTagReference'
|
||||
|
||||
export const isLegacyAnonymousReference = (x: ContentReference): x is LegacyAnonymousReference => {
|
||||
return (x as any).reference_type === undefined
|
||||
}
|
||||
|
||||
export const isReference = (x: ContentReference): x is Reference => {
|
||||
return (x as any).reference_type !== undefined
|
||||
}
|
||||
|
||||
export const isLegacyTagToNoteReference = (
|
||||
x: LegacyAnonymousReference,
|
||||
currentItem: ItemInterface,
|
||||
): x is LegacyTagToNoteReference => {
|
||||
const isReferenceToANote = x.content_type === ContentType.Note
|
||||
const isReferenceFromATag = currentItem.content_type === ContentType.Tag
|
||||
return isReferenceToANote && isReferenceFromATag
|
||||
}
|
||||
|
||||
export const isTagToParentTagReference = (x: ContentReference): x is TagToParentTagReference => {
|
||||
return isReference(x) && x.reference_type === ContenteReferenceType.TagToParentTag
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface LegacyAnonymousReference {
|
||||
uuid: string
|
||||
content_type: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { LegacyAnonymousReference } from './LegacyAnonymousReference'
|
||||
|
||||
export interface LegacyTagToNoteReference extends LegacyAnonymousReference {
|
||||
content_type: ContentType.Note
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { TagToParentTagReference } from './TagToParentTagReference'
|
||||
|
||||
export type Reference = TagToParentTagReference
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { AnonymousReference } from './AnonymousReference'
|
||||
import { ContenteReferenceType } from './ContenteReferenceType'
|
||||
|
||||
export interface TagToFileReference extends AnonymousReference {
|
||||
content_type: ContentType.File
|
||||
reference_type: ContenteReferenceType.TagToFile
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { AnonymousReference } from './AnonymousReference'
|
||||
import { ContenteReferenceType } from './ContenteReferenceType'
|
||||
|
||||
export interface TagToParentTagReference extends AnonymousReference {
|
||||
content_type: ContentType.Tag
|
||||
reference_type: ContenteReferenceType.TagToParentTag
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
import { TransferPayload } from './TransferPayload'
|
||||
|
||||
export interface DecryptedTransferPayload<C extends ItemContent = ItemContent> extends TransferPayload {
|
||||
content: C
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { TransferPayload } from './TransferPayload'
|
||||
|
||||
export interface DeletedTransferPayload extends TransferPayload {
|
||||
content: undefined
|
||||
deleted: true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TransferPayload } from './TransferPayload'
|
||||
|
||||
export interface EncryptedTransferPayload extends TransferPayload {
|
||||
content: string
|
||||
enc_item_key: string
|
||||
items_key_id: string | undefined
|
||||
errorDecrypting: boolean
|
||||
waitingForKey: boolean
|
||||
/** @deprecated */
|
||||
auth_hash?: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import { ItemContent } from '../../Content/ItemContent'
|
||||
|
||||
export interface TransferPayload<C extends ItemContent = ItemContent> {
|
||||
uuid: Uuid
|
||||
content_type: ContentType
|
||||
content: C | string | undefined
|
||||
deleted?: boolean
|
||||
|
||||
updated_at: Date
|
||||
created_at: Date
|
||||
created_at_timestamp: number
|
||||
updated_at_timestamp: number
|
||||
|
||||
dirtyIndex?: number
|
||||
globalDirtyIndexAtLastSync?: number
|
||||
dirty?: boolean
|
||||
|
||||
lastSyncBegan?: Date
|
||||
lastSyncEnd?: Date
|
||||
|
||||
duplicate_of?: Uuid
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { isObject, isString } from '@standardnotes/utils'
|
||||
import { DecryptedTransferPayload } from './DecryptedTransferPayload'
|
||||
import { DeletedTransferPayload } from './DeletedTransferPayload'
|
||||
import { EncryptedTransferPayload } from './EncryptedTransferPayload'
|
||||
import { TransferPayload } from './TransferPayload'
|
||||
|
||||
export type FullyFormedTransferPayload = DecryptedTransferPayload | EncryptedTransferPayload | DeletedTransferPayload
|
||||
|
||||
export function isDecryptedTransferPayload(payload: TransferPayload): payload is DecryptedTransferPayload {
|
||||
return isObject(payload.content)
|
||||
}
|
||||
|
||||
export function isEncryptedTransferPayload(payload: TransferPayload): payload is EncryptedTransferPayload {
|
||||
return 'content' in payload && isString(payload.content)
|
||||
}
|
||||
|
||||
export function isErrorDecryptingTransferPayload(payload: TransferPayload): payload is EncryptedTransferPayload {
|
||||
return isEncryptedTransferPayload(payload) && payload.errorDecrypting === true
|
||||
}
|
||||
|
||||
export function isDeletedTransferPayload(payload: TransferPayload): payload is DeletedTransferPayload {
|
||||
return 'deleted' in payload && payload.deleted === true
|
||||
}
|
||||
|
||||
export function isCorruptTransferPayload(payload: TransferPayload): boolean {
|
||||
const invalidDeletedState = payload.deleted === true && payload.content != undefined
|
||||
return payload.uuid == undefined || invalidDeletedState
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Interfaces/DecryptedTransferPayload'
|
||||
export * from './Interfaces/DeletedTransferPayload'
|
||||
export * from './Interfaces/EncryptedTransferPayload'
|
||||
export * from './Interfaces/TransferPayload'
|
||||
export * from './Interfaces/TypeCheck'
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
KeyParamsContent001,
|
||||
KeyParamsContent002,
|
||||
KeyParamsContent003,
|
||||
KeyParamsContent004,
|
||||
AnyKeyParamsContent,
|
||||
ProtocolVersion,
|
||||
KeyParamsOrigination,
|
||||
} from '@standardnotes/common'
|
||||
|
||||
/**
|
||||
* Key params are public data that contain information about how a root key was created.
|
||||
* Given a keyParams object and a password, clients can compute a root key that was created
|
||||
* previously.
|
||||
*/
|
||||
export interface RootKeyParamsInterface {
|
||||
readonly content: AnyKeyParamsContent
|
||||
|
||||
/**
|
||||
* For consumers to determine whether the object they are
|
||||
* working with is a proper RootKeyParams object.
|
||||
*/
|
||||
get isKeyParamsObject(): boolean
|
||||
|
||||
get identifier(): string
|
||||
|
||||
get version(): ProtocolVersion
|
||||
get origination(): KeyParamsOrigination | undefined
|
||||
|
||||
get content001(): KeyParamsContent001
|
||||
|
||||
get content002(): KeyParamsContent002
|
||||
|
||||
get content003(): KeyParamsContent003
|
||||
|
||||
get content004(): KeyParamsContent004
|
||||
|
||||
get createdDate(): Date | undefined
|
||||
|
||||
compare(other: RootKeyParamsInterface): boolean
|
||||
|
||||
/**
|
||||
* When saving in a file or communicating with server,
|
||||
* use the original values.
|
||||
*/
|
||||
getPortableValue(): AnyKeyParamsContent
|
||||
}
|
||||
31
packages/models/src/Domain/Local/RootKey/KeychainTypes.ts
Normal file
31
packages/models/src/Domain/Local/RootKey/KeychainTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApplicationIdentifier, ProtocolVersion } from '@standardnotes/common'
|
||||
import { RootKeyContentSpecialized } from './RootKeyContent'
|
||||
|
||||
export type RawKeychainValue = Record<ApplicationIdentifier, NamespacedRootKeyInKeychain>
|
||||
|
||||
export interface NamespacedRootKeyInKeychain {
|
||||
version: ProtocolVersion
|
||||
masterKey: string
|
||||
dataAuthenticationKey?: string
|
||||
}
|
||||
|
||||
export type RootKeyContentInStorage = RootKeyContentSpecialized
|
||||
|
||||
export interface LegacyRawKeychainValue {
|
||||
mk: string
|
||||
ak: string
|
||||
version: ProtocolVersion
|
||||
}
|
||||
|
||||
export type LegacyMobileKeychainStructure = {
|
||||
offline?: {
|
||||
timing?: unknown
|
||||
pw?: string
|
||||
}
|
||||
encryptedAccountKeys?: unknown
|
||||
mk: string
|
||||
pw: string
|
||||
ak: string
|
||||
version?: string
|
||||
jwt?: string
|
||||
}
|
||||
12
packages/models/src/Domain/Local/RootKey/RootKeyContent.ts
Normal file
12
packages/models/src/Domain/Local/RootKey/RootKeyContent.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ItemContent } from '../../Abstract/Content/ItemContent'
|
||||
import { ProtocolVersion, AnyKeyParamsContent } from '@standardnotes/common'
|
||||
|
||||
export interface RootKeyContentSpecialized {
|
||||
version: ProtocolVersion
|
||||
masterKey: string
|
||||
serverPassword?: string
|
||||
dataAuthenticationKey?: string
|
||||
keyParams: AnyKeyParamsContent
|
||||
}
|
||||
|
||||
export type RootKeyContent = RootKeyContentSpecialized & ItemContent
|
||||
17
packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts
Normal file
17
packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem'
|
||||
import { RootKeyParamsInterface } from '../KeyParams/RootKeyParamsInterface'
|
||||
import { NamespacedRootKeyInKeychain, RootKeyContentInStorage } from './KeychainTypes'
|
||||
import { RootKeyContent } from './RootKeyContent'
|
||||
|
||||
export interface RootKeyInterface extends DecryptedItemInterface<RootKeyContent> {
|
||||
readonly keyParams: RootKeyParamsInterface
|
||||
get keyVersion(): ProtocolVersion
|
||||
get itemsKey(): string
|
||||
get masterKey(): string
|
||||
get serverPassword(): string | undefined
|
||||
get dataAuthenticationKey(): string | undefined
|
||||
compare(otherKey: RootKeyInterface): boolean
|
||||
persistableValueWhenWrapping(): RootKeyContentInStorage
|
||||
getKeychainValue(): NamespacedRootKeyInKeychain
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user