internal: incomplete vault systems behind feature flag (#2340)

This commit is contained in:
Mo
2023-06-30 09:01:56 -05:00
committed by GitHub
parent d16e401bb9
commit b032eb9c9b
638 changed files with 20321 additions and 4813 deletions

View File

@@ -0,0 +1,3 @@
export type AsymmetricMessageDataCommon = {
recipientUuid: string
}

View File

@@ -0,0 +1,12 @@
import { AsymmetricMessageSenderKeypairChanged } from './MessageTypes/AsymmetricMessageSenderKeypairChanged'
import { AsymmetricMessageSharedVaultInvite } from './MessageTypes/AsymmetricMessageSharedVaultInvite'
import { AsymmetricMessageSharedVaultMetadataChanged } from './MessageTypes/AsymmetricMessageSharedVaultMetadataChanged'
import { AsymmetricMessageSharedVaultRootKeyChanged } from './MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged'
import { AsymmetricMessageTrustedContactShare } from './MessageTypes/AsymmetricMessageTrustedContactShare'
export type AsymmetricMessagePayload =
| AsymmetricMessageSharedVaultRootKeyChanged
| AsymmetricMessageTrustedContactShare
| AsymmetricMessageSenderKeypairChanged
| AsymmetricMessageSharedVaultInvite
| AsymmetricMessageSharedVaultMetadataChanged

View File

@@ -0,0 +1,7 @@
export enum AsymmetricMessagePayloadType {
ContactShare = 'contact-share',
SharedVaultRootKeyChanged = 'shared-vault-root-key-changed',
SenderKeypairChanged = 'sender-keypair-changed',
SharedVaultInvite = 'shared-vault-invite',
SharedVaultMetadataChanged = 'shared-vault-metadata-changed',
}

View File

@@ -0,0 +1,10 @@
import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon'
import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType'
export type AsymmetricMessageSenderKeypairChanged = {
type: AsymmetricMessagePayloadType.SenderKeypairChanged
data: AsymmetricMessageDataCommon & {
newEncryptionPublicKey: string
newSigningPublicKey: string
}
}

View File

@@ -0,0 +1,16 @@
import { KeySystemRootKeyContentSpecialized } from '../../../Syncable/KeySystemRootKey/KeySystemRootKeyContent'
import { TrustedContactContentSpecialized } from '../../../Syncable/TrustedContact/TrustedContactContent'
import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon'
import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType'
export type AsymmetricMessageSharedVaultInvite = {
type: AsymmetricMessagePayloadType.SharedVaultInvite
data: AsymmetricMessageDataCommon & {
rootKey: KeySystemRootKeyContentSpecialized
trustedContacts: TrustedContactContentSpecialized[]
metadata: {
name: string
description?: string
}
}
}

View File

@@ -0,0 +1,11 @@
import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon'
import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType'
export type AsymmetricMessageSharedVaultMetadataChanged = {
type: AsymmetricMessagePayloadType.SharedVaultMetadataChanged
data: AsymmetricMessageDataCommon & {
sharedVaultUuid: string
name: string
description?: string
}
}

View File

@@ -0,0 +1,8 @@
import { KeySystemRootKeyContentSpecialized } from '../../../Syncable/KeySystemRootKey/KeySystemRootKeyContent'
import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon'
import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType'
export type AsymmetricMessageSharedVaultRootKeyChanged = {
type: AsymmetricMessagePayloadType.SharedVaultRootKeyChanged
data: AsymmetricMessageDataCommon & { rootKey: KeySystemRootKeyContentSpecialized }
}

View File

@@ -0,0 +1,8 @@
import { TrustedContactContentSpecialized } from '../../../Syncable/TrustedContact/TrustedContactContent'
import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon'
import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType'
export type AsymmetricMessageTrustedContactShare = {
type: AsymmetricMessagePayloadType.ContactShare
data: AsymmetricMessageDataCommon & { trustedContact: TrustedContactContentSpecialized }
}

View File

@@ -1,10 +1,10 @@
import { ItemCounter } from './ItemCounter'
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 { TagItemsIndex } from './TagItemsIndex'
import { ItemDelta } from '../../Index/ItemDelta'
import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes'
@@ -48,7 +48,7 @@ describe('tag notes index', () => {
it('should count both notes and files', () => {
const collection = new ItemCollection()
const index = new TagItemsIndex(collection)
const index = new ItemCounter(collection)
const decryptedNote = createDecryptedItem('note')
const decryptedFile = createDecryptedItem('file')
@@ -61,7 +61,7 @@ describe('tag notes index', () => {
it('should decrement count after decrypted note becomes errored', () => {
const collection = new ItemCollection()
const index = new TagItemsIndex(collection)
const index = new ItemCounter(collection)
const decryptedItem = createDecryptedItem()
collection.set(decryptedItem)

View File

@@ -4,25 +4,28 @@ 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'
import { DecryptedItemInterface, isDecryptedItem, ItemInterface } from '../../../Abstract/Item'
import { CriteriaValidatorInterface } from '../../Display/Validator/CriteriaValidatorInterface'
import { CollectionCriteriaValidator } from '../../Display/Validator/CollectionCriteriaValidator'
import { ExcludeVaultsCriteriaValidator } from '../../Display/Validator/ExcludeVaultsCriteriaValidator'
import { ExclusiveVaultCriteriaValidator } from '../../Display/Validator/ExclusiveVaultCriteriaValidator'
import { HiddenContentCriteriaValidator } from '../../Display/Validator/HiddenContentCriteriaValidator'
import { CustomFilterCriteriaValidator } from '../../Display/Validator/CustomFilterCriteriaValidator'
import { AnyDisplayOptions, VaultDisplayOptions } from '../../Display'
import { isExclusioanaryOptionsValue } from '../../Display/VaultDisplayOptionsTypes'
type AllNotesUuidSignifier = undefined
export type TagItemCountChangeObserver = (tagUuid: string | AllNotesUuidSignifier) => void
export class TagItemsIndex implements SNIndex {
export class ItemCounter implements SNIndex {
private tagToItemsMap: Partial<Record<string, Set<string>>> = {}
private allCountableItems = new Set<string>()
private countableItemsByType = new Map<ContentType, Set<string>>()
private displayOptions?: AnyDisplayOptions
private vaultDisplayOptions?: VaultDisplayOptions
constructor(private collection: ItemCollection, public observers: TagItemCountChangeObserver[] = []) {}
private isItemCountable = (item: ItemInterface) => {
if (isDecryptedItem(item)) {
return !item.archived && !item.trashed && !item.conflictOf
}
return false
}
public addCountChangeObserver(observer: TagItemCountChangeObserver): () => void {
this.observers.push(observer)
@@ -32,10 +35,14 @@ export class TagItemsIndex implements SNIndex {
}
}
private notifyObservers(tagUuid: string | undefined) {
for (const observer of this.observers) {
observer(tagUuid)
}
public setDisplayOptions(options: AnyDisplayOptions) {
this.displayOptions = options
this.receiveItemChanges(this.collection.all())
}
public setVaultDisplayOptions(options: VaultDisplayOptions) {
this.vaultDisplayOptions = options
this.receiveItemChanges(this.collection.all())
}
public allCountableItemsCount(): number {
@@ -64,6 +71,47 @@ export class TagItemsIndex implements SNIndex {
this.receiveTagChanges(tags)
}
private passesAllFilters(element: DecryptedItemInterface): boolean {
if (!this.displayOptions) {
return true
}
const filters: CriteriaValidatorInterface[] = [new CollectionCriteriaValidator(this.collection, element)]
if (this.vaultDisplayOptions) {
const options = this.vaultDisplayOptions.getOptions()
if (isExclusioanaryOptionsValue(options)) {
filters.push(new ExcludeVaultsCriteriaValidator([...options.exclude, ...options.locked], element))
} else {
filters.push(new ExclusiveVaultCriteriaValidator(options.exclusive, element))
}
}
if ('hiddenContentTypes' in this.displayOptions && this.displayOptions.hiddenContentTypes) {
filters.push(new HiddenContentCriteriaValidator(this.displayOptions.hiddenContentTypes, element))
}
if ('customFilter' in this.displayOptions && this.displayOptions.customFilter) {
filters.push(new CustomFilterCriteriaValidator(this.displayOptions.customFilter, element))
}
return filters.every((f) => f.passes())
}
private isItemCountable = (item: ItemInterface) => {
if (isDecryptedItem(item)) {
const passesFilters = this.passesAllFilters(item)
return passesFilters && !item.archived && !item.trashed && !item.conflictOf
}
return false
}
private notifyObservers(tagUuid: string | undefined) {
for (const observer of this.observers) {
observer(tagUuid)
}
}
private receiveTagChanges(tags: SNTag[]): void {
for (const tag of tags) {
const uuids = tag.references

View File

@@ -1,15 +1,16 @@
import { ConflictDelta } from './Conflict'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { FullyFormedPayloadInterface, 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'
import { ConflictConflictingDataParams } from '@standardnotes/responses'
export class DeltaRemoteDataConflicts implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
readonly conflicts: ConflictConflictingDataParams<FullyFormedPayloadInterface>[],
readonly historyMap: HistoryMap,
) {}
@@ -20,18 +21,18 @@ export class DeltaRemoteDataConflicts implements SyncDeltaInterface {
source: PayloadEmitSource.RemoteRetrieved,
}
for (const apply of this.applyCollection.all()) {
const base = this.baseCollection.find(apply.uuid)
for (const conflict of this.conflicts) {
const base = this.baseCollection.find(conflict.server_item.uuid)
const isBaseDeleted = base == undefined
if (isBaseDeleted) {
result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection))
result.emits.push(payloadByFinalizingSyncState(conflict.server_item, this.baseCollection))
continue
}
const delta = new ConflictDelta(this.baseCollection, base, apply, this.historyMap)
const delta = new ConflictDelta(this.baseCollection, base, conflict.server_item, this.historyMap)
extendSyncDelta(result, delta.result())
}

View File

@@ -1,10 +1,14 @@
import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent'
import { DecryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload'
import { DecryptedPayload, FullyFormedPayloadInterface, 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'
import { ConflictParams, ConflictType } from '@standardnotes/responses'
import { UuidGenerator } from '@standardnotes/utils'
UuidGenerator.SetGenerator(() => String(Math.random()))
describe('remote rejected delta', () => {
it('rejected payloads should not map onto app state', async () => {
@@ -30,10 +34,12 @@ describe('remote rejected delta', () => {
dirty: true,
})
const delta = new DeltaRemoteRejected(
ImmutablePayloadCollection.FromCollection(baseCollection),
ImmutablePayloadCollection.WithPayloads([rejectedPayload]),
)
const entry: ConflictParams<FullyFormedPayloadInterface> = {
type: ConflictType.ContentTypeError,
unsaved_item: rejectedPayload,
} as unknown as ConflictParams<FullyFormedPayloadInterface>
const delta = new DeltaRemoteRejected(ImmutablePayloadCollection.FromCollection(baseCollection), [entry])
const result = delta.result()
const payload = result.emits[0] as DecryptedPayload<NoteContent>

View File

@@ -1,35 +1,48 @@
import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource'
import { PayloadEmitSource } from '../../Abstract/Payload'
import { DeletedPayload, FullyFormedPayloadInterface, 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'
import { BuildSyncResolvedParams, SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import {
ConflictParams,
ConflictParamsWithServerItem,
ConflictParamsWithUnsavedItem,
ConflictParamsWithServerAndUnsavedItem,
conflictParamsHasServerItemAndUnsavedItem,
conflictParamsHasOnlyServerItem,
conflictParamsHasOnlyUnsavedItem,
ConflictType,
} from '@standardnotes/responses'
import { PayloadsByDuplicating } from '../../Utilities/Payload/PayloadsByDuplicating'
import { ContentType } from '@standardnotes/common'
export class DeltaRemoteRejected implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
readonly conflicts: ConflictParams<FullyFormedPayloadInterface>[],
) {}
public result(): SyncDeltaEmit {
const results: SyncResolvedPayload[] = []
for (const apply of this.applyCollection.all()) {
const base = this.baseCollection.find(apply.uuid)
const vaultErrors: ConflictType[] = [
ConflictType.SharedVaultInsufficientPermissionsError,
ConflictType.SharedVaultNotMemberError,
ConflictType.SharedVaultInvalidState,
ConflictType.SharedVaultSnjsVersionError,
]
if (!base) {
continue
for (const conflict of this.conflicts) {
if (vaultErrors.includes(conflict.type)) {
results.push(...this.handleVaultError(conflict))
} else if (conflictParamsHasServerItemAndUnsavedItem(conflict)) {
results.push(...this.getResultForConflictWithServerItemAndUnsavedItem(conflict))
} else if (conflictParamsHasOnlyServerItem(conflict)) {
results.push(...this.getResultForConflictWithOnlyServerItem(conflict))
} else if (conflictParamsHasOnlyUnsavedItem(conflict)) {
results.push(...this.getResultForConflictWithOnlyUnsavedItem(conflict))
}
const result = base.copyAsSyncResolved(
{
dirty: false,
lastSyncEnd: new Date(),
},
PayloadSource.RemoteSaved,
)
results.push(result)
}
return {
@@ -37,4 +50,177 @@ export class DeltaRemoteRejected implements SyncDeltaInterface {
source: PayloadEmitSource.RemoteSaved,
}
}
private handleVaultError(conflict: ConflictParams<FullyFormedPayloadInterface>): SyncResolvedPayload[] {
const base = this.baseCollection.find(conflict.unsaved_item.uuid)
if (!base) {
return []
}
if (conflict.type === ConflictType.SharedVaultNotMemberError) {
return this.resultByDuplicatingBasePayloadAsNonVaultedAndRemovingBaseItemLocally(base)
}
if (base.content_type === ContentType.KeySystemItemsKey) {
return this.discardChangesOfBasePayload(base)
}
if (conflict.server_item) {
return this.resultByDuplicatingBasePayloadAsNonVaultedAndTakingServerPayloadAsCanonical(
base,
conflict.server_item,
)
} else {
return this.resultByDuplicatingBasePayloadAsNonVaultedAndDiscardingChangesOfOriginal(base)
}
}
private discardChangesOfBasePayload(base: FullyFormedPayloadInterface): SyncResolvedPayload[] {
const undirtiedPayload = base.copyAsSyncResolved(
{
dirty: false,
lastSyncEnd: new Date(),
},
PayloadSource.RemoteSaved,
)
return [undirtiedPayload]
}
private getResultForConflictWithOnlyUnsavedItem(
conflict: ConflictParamsWithUnsavedItem<FullyFormedPayloadInterface>,
): SyncResolvedPayload[] {
const base = this.baseCollection.find(conflict.unsaved_item.uuid)
if (!base) {
return []
}
const result = base.copyAsSyncResolved(
{
dirty: false,
lastSyncEnd: new Date(),
},
PayloadSource.RemoteSaved,
)
return [result]
}
private getResultForConflictWithOnlyServerItem(
conflict: ConflictParamsWithServerItem<FullyFormedPayloadInterface>,
): SyncResolvedPayload[] {
const base = this.baseCollection.find(conflict.server_item.uuid)
if (!base) {
return []
}
return this.resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical(base, conflict.server_item)
}
private getResultForConflictWithServerItemAndUnsavedItem(
conflict: ConflictParamsWithServerAndUnsavedItem<FullyFormedPayloadInterface>,
): SyncResolvedPayload[] {
const base = this.baseCollection.find(conflict.server_item.uuid)
if (!base) {
return []
}
return this.resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical(base, conflict.server_item)
}
private resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical(
basePayload: FullyFormedPayloadInterface,
serverPayload: FullyFormedPayloadInterface,
): SyncResolvedPayload[] {
const duplicateBasePayloadIntoNewUuid = PayloadsByDuplicating({
payload: basePayload,
baseCollection: this.baseCollection,
isConflict: true,
source: serverPayload.source,
})
const takeServerPayloadAsCanonical = serverPayload.copyAsSyncResolved(
{
lastSyncBegan: basePayload.lastSyncBegan,
dirty: false,
lastSyncEnd: new Date(),
},
serverPayload.source,
)
return duplicateBasePayloadIntoNewUuid.concat([takeServerPayloadAsCanonical])
}
private resultByDuplicatingBasePayloadAsNonVaultedAndTakingServerPayloadAsCanonical(
basePayload: FullyFormedPayloadInterface,
serverPayload: FullyFormedPayloadInterface,
): SyncResolvedPayload[] {
const duplicateBasePayloadIntoNewUuid = PayloadsByDuplicating({
payload: basePayload.copy({
key_system_identifier: undefined,
shared_vault_uuid: undefined,
}),
baseCollection: this.baseCollection,
isConflict: true,
source: serverPayload.source,
})
const takeServerPayloadAsCanonical = serverPayload.copyAsSyncResolved(
{
lastSyncBegan: basePayload.lastSyncBegan,
dirty: false,
lastSyncEnd: new Date(),
},
serverPayload.source,
)
return duplicateBasePayloadIntoNewUuid.concat([takeServerPayloadAsCanonical])
}
private resultByDuplicatingBasePayloadAsNonVaultedAndDiscardingChangesOfOriginal(
basePayload: FullyFormedPayloadInterface,
): SyncResolvedPayload[] {
const duplicateBasePayloadWithoutVault = PayloadsByDuplicating({
payload: basePayload.copy({
key_system_identifier: undefined,
shared_vault_uuid: undefined,
}),
baseCollection: this.baseCollection,
isConflict: true,
source: basePayload.source,
})
return [...duplicateBasePayloadWithoutVault, ...this.discardChangesOfBasePayload(basePayload)]
}
private resultByDuplicatingBasePayloadAsNonVaultedAndRemovingBaseItemLocally(
basePayload: FullyFormedPayloadInterface,
): SyncResolvedPayload[] {
const duplicateBasePayloadWithoutVault = PayloadsByDuplicating({
payload: basePayload.copy({
key_system_identifier: undefined,
shared_vault_uuid: undefined,
}),
baseCollection: this.baseCollection,
isConflict: true,
source: basePayload.source,
})
const locallyDeletedBasePayload = new DeletedPayload(
{
...basePayload,
content: undefined,
deleted: true,
key_system_identifier: undefined,
shared_vault_uuid: undefined,
...BuildSyncResolvedParams({
dirty: false,
lastSyncEnd: new Date(),
}),
},
PayloadSource.RemoteSaved,
)
return [...duplicateBasePayloadWithoutVault, locallyDeletedBasePayload as SyncResolvedPayload]
}
}

View File

@@ -36,7 +36,7 @@ export class DeltaRemoteRetrieved implements SyncDeltaInterface {
* 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) {
if (apply.content_type === ContentType.ItemsKey || apply.content_type === ContentType.KeySystemItemsKey) {
const itemsKeyDeltaEmit = new ItemsKeyDelta(this.baseCollection, [apply]).result()
extendSyncDelta(result, itemsKeyDeltaEmit)

View File

@@ -1,4 +1,4 @@
import { ServerSyncSavedContextualPayload } from './../../Abstract/Contextual/ServerSyncSaved'
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'

View File

@@ -2,10 +2,11 @@ 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 { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload'
import { SyncDeltaEmit } from './Abstract/DeltaEmit'
import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface'
import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
import { ConflictUuidConflictParams } from '@standardnotes/responses'
/**
* UUID conflicts can occur if a user attmpts to import an old data
@@ -15,22 +16,22 @@ import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload'
export class DeltaRemoteUuidConflicts implements SyncDeltaInterface {
constructor(
readonly baseCollection: ImmutablePayloadCollection,
readonly applyCollection: ImmutablePayloadCollection,
readonly conflicts: ConflictUuidConflictParams<FullyFormedPayloadInterface>[],
) {}
public result(): SyncDeltaEmit {
const results: SyncResolvedPayload[] = []
const baseCollectionCopy = this.baseCollection.mutableCopy()
for (const apply of this.applyCollection.all()) {
for (const conflict of this.conflicts) {
/**
* 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
const moreRecent = results.find((r) => r.uuid === conflict.unsaved_item.uuid)
const useApply = moreRecent || conflict.unsaved_item
if (!isDecryptedPayload(useApply)) {
continue

View File

@@ -1,8 +1,8 @@
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'
import { notesAndFilesMatchingOptions } from './Search/SearchUtilities'
import { NotesAndFilesDisplayOptions } from './DisplayOptions'
describe('item display options', () => {
const collectionWithNotes = function (titles: (string | undefined)[] = [], bodies: string[] = []) {
@@ -23,31 +23,31 @@ describe('item display options', () => {
it('string query title', () => {
const query = 'foo'
const options: FilterDisplayOptions = {
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query: query, includeProtectedNoteText: true },
}
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['hello', 'fobar', 'foobar', 'foo'])
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
it('string query text', async function () {
const query = 'foo'
const options: FilterDisplayOptions = {
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query: query, includeProtectedNoteText: true },
}
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(
[undefined, undefined, undefined, undefined],
['hello', 'fobar', 'foobar', 'foo'],
)
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
it('string query title and text', async function () {
const query = 'foo'
const options: FilterDisplayOptions = {
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query: query, includeProtectedNoteText: true },
}
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar'])
expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
})

View File

@@ -5,21 +5,28 @@ import { CollectionSortDirection, CollectionSortProperty } from '../Collection/C
import { SearchQuery } from './Search/Types'
import { DisplayControllerCustomFilter } from './Types'
export type DisplayOptions = FilterDisplayOptions & DisplayControllerOptions
export interface FilterDisplayOptions {
tags?: SNTag[]
views?: SmartView[]
searchQuery?: SearchQuery
export interface GenericDisplayOptions {
includePinned?: boolean
includeProtected?: boolean
includeTrashed?: boolean
includeArchived?: boolean
}
export interface DisplayControllerOptions {
sortBy: CollectionSortProperty
sortDirection: CollectionSortDirection
export interface NotesAndFilesDisplayOptions extends GenericDisplayOptions {
tags?: SNTag[]
views?: SmartView[]
searchQuery?: SearchQuery
hiddenContentTypes?: ContentType[]
customFilter?: DisplayControllerCustomFilter
}
export type TagsDisplayOptions = GenericDisplayOptions
export interface DisplayControllerDisplayOptions extends GenericDisplayOptions {
sortBy: CollectionSortProperty
sortDirection: CollectionSortDirection
}
export type NotesAndFilesDisplayControllerOptions = NotesAndFilesDisplayOptions & DisplayControllerDisplayOptions
export type TagsDisplayControllerOptions = TagsDisplayOptions & DisplayControllerDisplayOptions
export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsDisplayOptions | GenericDisplayOptions

View File

@@ -5,11 +5,11 @@ 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'
import { NotesAndFilesDisplayOptions } from './DisplayOptions'
import { SystemViewId } from '../../Syncable/SmartView'
export function computeUnifiedFilterForDisplayOptions(
options: FilterDisplayOptions,
options: NotesAndFilesDisplayOptions,
collection: ReferenceLookupCollection,
additionalFilters: ItemFilter[] = [],
): ItemFilter {
@@ -21,7 +21,7 @@ export function computeUnifiedFilterForDisplayOptions(
}
export function computeFiltersForDisplayOptions(
options: FilterDisplayOptions,
options: NotesAndFilesDisplayOptions,
collection: ReferenceLookupCollection,
): ItemFilter[] {
const filters: ItemFilter[] = []

View File

@@ -2,11 +2,19 @@ import { ContentType } from '@standardnotes/common'
import { compareValues } from '@standardnotes/utils'
import { isDeletedItem, isEncryptedItem } from '../../Abstract/Item'
import { ItemDelta } from '../Index/ItemDelta'
import { DisplayControllerOptions } from './DisplayOptions'
import { AnyDisplayOptions, DisplayControllerDisplayOptions, GenericDisplayOptions } from './DisplayOptions'
import { sortTwoItems } from './SortTwoItems'
import { UuidToSortedPositionMap, DisplayItem, ReadonlyItemCollection } from './Types'
import { CriteriaValidatorInterface } from './Validator/CriteriaValidatorInterface'
import { CollectionCriteriaValidator } from './Validator/CollectionCriteriaValidator'
import { CustomFilterCriteriaValidator } from './Validator/CustomFilterCriteriaValidator'
import { ExcludeVaultsCriteriaValidator } from './Validator/ExcludeVaultsCriteriaValidator'
import { ExclusiveVaultCriteriaValidator } from './Validator/ExclusiveVaultCriteriaValidator'
import { HiddenContentCriteriaValidator } from './Validator/HiddenContentCriteriaValidator'
import { VaultDisplayOptions } from './VaultDisplayOptions'
import { isExclusioanaryOptionsValue } from './VaultDisplayOptionsTypes'
export class ItemDisplayController<I extends DisplayItem> {
export class ItemDisplayController<I extends DisplayItem, O extends AnyDisplayOptions = GenericDisplayOptions> {
private sortMap: UuidToSortedPositionMap = {}
private sortedItems: I[] = []
private needsSort = true
@@ -14,7 +22,8 @@ export class ItemDisplayController<I extends DisplayItem> {
constructor(
private readonly collection: ReadonlyItemCollection,
public readonly contentTypes: ContentType[],
private options: DisplayControllerOptions,
private options: DisplayControllerDisplayOptions & O,
private vaultOptions?: VaultDisplayOptions,
) {
this.filterThenSortElements(this.collection.all(this.contentTypes) as I[])
}
@@ -23,7 +32,18 @@ export class ItemDisplayController<I extends DisplayItem> {
return this.sortedItems
}
setDisplayOptions(displayOptions: Partial<DisplayControllerOptions>): void {
public getDisplayOptions(): DisplayControllerDisplayOptions & O {
return this.options
}
setVaultDisplayOptions(vaultOptions?: VaultDisplayOptions): void {
this.vaultOptions = vaultOptions
this.needsSort = true
this.filterThenSortElements(this.collection.all(this.contentTypes) as I[])
}
setDisplayOptions(displayOptions: Partial<DisplayControllerDisplayOptions & O>): void {
this.options = { ...this.options, ...displayOptions }
this.needsSort = true
@@ -37,6 +57,29 @@ export class ItemDisplayController<I extends DisplayItem> {
this.filterThenSortElements(items as I[])
}
private passesAllFilters(element: I): boolean {
const filters: CriteriaValidatorInterface[] = [new CollectionCriteriaValidator(this.collection, element)]
if (this.vaultOptions) {
const options = this.vaultOptions.getOptions()
if (isExclusioanaryOptionsValue(options)) {
filters.push(new ExcludeVaultsCriteriaValidator([...options.exclude, ...options.locked], element))
} else {
filters.push(new ExclusiveVaultCriteriaValidator(options.exclusive, element))
}
}
if ('hiddenContentTypes' in this.options && this.options.hiddenContentTypes) {
filters.push(new HiddenContentCriteriaValidator(this.options.hiddenContentTypes, element))
}
if ('customFilter' in this.options && this.options.customFilter) {
filters.push(new CustomFilterCriteriaValidator(this.options.customFilter, element))
}
return filters.every((f) => f.passes())
}
private filterThenSortElements(elements: I[]): void {
for (const element of elements) {
const previousIndex = this.sortMap[element.uuid]
@@ -61,13 +104,7 @@ export class ItemDisplayController<I extends DisplayItem> {
continue
}
const passes = !this.collection.has(element.uuid)
? false
: this.options.hiddenContentTypes?.includes(element.content_type)
? false
: this.options.customFilter
? this.options.customFilter(element)
: true
const passes = this.passesAllFilters(element)
if (passes) {
if (previousElement != undefined) {

View File

@@ -1,6 +1,6 @@
import { ContentType } from '@standardnotes/common'
import { SNTag } from '../../../Syncable/Tag'
import { FilterDisplayOptions } from '../DisplayOptions'
import { NotesAndFilesDisplayOptions } from '../DisplayOptions'
import { computeFiltersForDisplayOptions } from '../DisplayOptionsToFilters'
import { SearchableItem } from './SearchableItem'
import { ReferenceLookupCollection, ItemFilter, SearchQuery, SearchableDecryptedItem } from './Types'
@@ -13,8 +13,8 @@ enum MatchResult {
Uuid = 5,
}
export function itemsMatchingOptions(
options: FilterDisplayOptions,
export function notesAndFilesMatchingOptions(
options: NotesAndFilesDisplayOptions,
fromItems: SearchableDecryptedItem[],
collection: ReferenceLookupCollection,
): SearchableItem[] {

View File

@@ -0,0 +1,11 @@
import { ItemInterface } from '../../../Abstract/Item'
import { ReadonlyItemCollection } from '../Types'
import { CriteriaValidatorInterface } from './CriteriaValidatorInterface'
export class CollectionCriteriaValidator implements CriteriaValidatorInterface {
constructor(private collection: ReadonlyItemCollection, private element: ItemInterface) {}
public passes(): boolean {
return this.collection.has(this.element.uuid)
}
}

View File

@@ -0,0 +1,3 @@
export interface CriteriaValidatorInterface {
passes(): boolean
}

View File

@@ -0,0 +1,11 @@
import { DecryptedItemInterface } from '../../../Abstract/Item'
import { DisplayControllerCustomFilter } from '../Types'
import { CriteriaValidatorInterface } from './CriteriaValidatorInterface'
export class CustomFilterCriteriaValidator implements CriteriaValidatorInterface {
constructor(private customFilter: DisplayControllerCustomFilter, private element: DecryptedItemInterface) {}
public passes(): boolean {
return this.customFilter(this.element)
}
}

View File

@@ -0,0 +1,15 @@
import { CriteriaValidatorInterface } from './CriteriaValidatorInterface'
import { DecryptedItemInterface } from '../../../Abstract/Item'
import { KeySystemIdentifier } from '../../../Syncable/KeySystemRootKey/KeySystemIdentifier'
export class ExcludeVaultsCriteriaValidator implements CriteriaValidatorInterface {
constructor(private excludeVaults: KeySystemIdentifier[], private element: DecryptedItemInterface) {}
public passes(): boolean {
const doesElementBelongToExcludedVault = this.excludeVaults.some(
(vault) => this.element.key_system_identifier === vault,
)
return !doesElementBelongToExcludedVault
}
}

View File

@@ -0,0 +1,11 @@
import { CriteriaValidatorInterface } from './CriteriaValidatorInterface'
import { DecryptedItemInterface } from '../../../Abstract/Item'
import { KeySystemIdentifier } from '../../../Syncable/KeySystemRootKey/KeySystemIdentifier'
export class ExclusiveVaultCriteriaValidator implements CriteriaValidatorInterface {
constructor(private exclusiveVault: KeySystemIdentifier, private element: DecryptedItemInterface) {}
public passes(): boolean {
return this.element.key_system_identifier === this.exclusiveVault
}
}

View File

@@ -0,0 +1,11 @@
import { DecryptedItemInterface } from './../../../Abstract/Item/Interfaces/DecryptedItem'
import { ContentType } from '@standardnotes/common'
import { CriteriaValidatorInterface } from './CriteriaValidatorInterface'
export class HiddenContentCriteriaValidator implements CriteriaValidatorInterface {
constructor(private hiddenContentTypes: ContentType[], private element: DecryptedItemInterface) {}
public passes(): boolean {
return !this.hiddenContentTypes.includes(this.element.content_type)
}
}

View File

@@ -0,0 +1,109 @@
import { VaultListingInterface } from '../../Syncable/VaultListing/VaultListingInterface'
import { uniqueArray } from '@standardnotes/utils'
import {
ExclusioanaryOptions,
ExclusiveOptions,
VaultDisplayOptionsPersistable,
isExclusioanaryOptionsValue,
} from './VaultDisplayOptionsTypes'
import { KeySystemIdentifier } from '../../Syncable/KeySystemRootKey/KeySystemIdentifier'
function KeySystemIdentifiers(vaults: VaultListingInterface[]): KeySystemIdentifier[] {
return vaults.map((vault) => vault.systemIdentifier)
}
export class VaultDisplayOptions {
constructor(private readonly options: ExclusioanaryOptions | ExclusiveOptions) {}
public getOptions(): ExclusioanaryOptions | ExclusiveOptions {
return this.options
}
public getExclusivelyShownVault(): KeySystemIdentifier {
if (isExclusioanaryOptionsValue(this.options)) {
throw new Error('Not in exclusive display mode')
}
return this.options.exclusive
}
public isInExclusiveDisplayMode(): boolean {
return !isExclusioanaryOptionsValue(this.options)
}
public isVaultExplicitelyExcluded(vault: VaultListingInterface): boolean {
if (isExclusioanaryOptionsValue(this.options)) {
return this.options.exclude.some((excludedVault) => excludedVault === vault.systemIdentifier)
} else if (this.options.exclusive) {
return this.options.exclusive !== vault.systemIdentifier
}
throw new Error('Invalid vault display options')
}
isVaultExclusivelyShown(vault: VaultListingInterface): boolean {
return !isExclusioanaryOptionsValue(this.options) && this.options.exclusive === vault.systemIdentifier
}
isVaultDisabledOrLocked(vault: VaultListingInterface): boolean {
if (isExclusioanaryOptionsValue(this.options)) {
const matchingLocked = this.options.locked.find((lockedVault) => lockedVault === vault.systemIdentifier)
if (matchingLocked) {
return true
}
}
return this.isVaultExplicitelyExcluded(vault)
}
getPersistableValue(): VaultDisplayOptionsPersistable {
return this.options
}
newOptionsByIntakingLockedVaults(lockedVaults: VaultListingInterface[]): VaultDisplayOptions {
if (isExclusioanaryOptionsValue(this.options)) {
return new VaultDisplayOptions({ exclude: this.options.exclude, locked: KeySystemIdentifiers(lockedVaults) })
} else {
return new VaultDisplayOptions({ exclusive: this.options.exclusive })
}
}
newOptionsByExcludingVault(vault: VaultListingInterface, lockedVaults: VaultListingInterface[]): VaultDisplayOptions {
return this.newOptionsByExcludingVaults([vault], lockedVaults)
}
newOptionsByExcludingVaults(
vaults: VaultListingInterface[],
lockedVaults: VaultListingInterface[],
): VaultDisplayOptions {
if (isExclusioanaryOptionsValue(this.options)) {
return new VaultDisplayOptions({
exclude: uniqueArray([...this.options.exclude, ...KeySystemIdentifiers(vaults)]),
locked: KeySystemIdentifiers(lockedVaults),
})
} else {
return new VaultDisplayOptions({
exclude: KeySystemIdentifiers(vaults),
locked: KeySystemIdentifiers(lockedVaults),
})
}
}
newOptionsByUnexcludingVault(
vault: VaultListingInterface,
lockedVaults: VaultListingInterface[],
): VaultDisplayOptions {
if (isExclusioanaryOptionsValue(this.options)) {
return new VaultDisplayOptions({
exclude: this.options.exclude.filter((excludedVault) => excludedVault !== vault.systemIdentifier),
locked: KeySystemIdentifiers(lockedVaults),
})
} else {
return new VaultDisplayOptions({ exclude: [], locked: KeySystemIdentifiers(lockedVaults) })
}
}
static FromPersistableValue(value: VaultDisplayOptionsPersistable): VaultDisplayOptions {
return new VaultDisplayOptions(value)
}
}

View File

@@ -0,0 +1,12 @@
import { KeySystemIdentifier } from '../../Syncable/KeySystemRootKey/KeySystemIdentifier'
export type ExclusioanaryOptions = { exclude: KeySystemIdentifier[]; locked: KeySystemIdentifier[] }
export type ExclusiveOptions = { exclusive: KeySystemIdentifier }
export function isExclusioanaryOptionsValue(
options: ExclusioanaryOptions | ExclusiveOptions,
): options is ExclusioanaryOptions {
return 'exclude' in options || 'locked' in options
}
export type VaultDisplayOptionsPersistable = ExclusioanaryOptions | ExclusiveOptions

View File

@@ -6,3 +6,5 @@ export * from './Search/SearchableItem'
export * from './Search/SearchUtilities'
export * from './Search/Types'
export * from './Types'
export * from './VaultDisplayOptions'
export * from './VaultDisplayOptionsTypes'

View File

@@ -0,0 +1,5 @@
import { ContentType } from '@standardnotes/common'
export function ContentTypeUsesKeySystemRootKeyEncryption(contentType: ContentType): boolean {
return contentType === ContentType.KeySystemItemsKey
}

View File

@@ -0,0 +1,6 @@
import { ContentType } from '@standardnotes/common'
import { ContentTypesUsingRootKeyEncryption } from './ContentTypesUsingRootKeyEncryption'
export function ContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean {
return ContentTypesUsingRootKeyEncryption().includes(contentType)
}

View File

@@ -0,0 +1,11 @@
import { ContentType } from '@standardnotes/common'
export function ContentTypesUsingRootKeyEncryption(): ContentType[] {
return [
ContentType.RootKey,
ContentType.ItemsKey,
ContentType.EncryptedStorage,
ContentType.TrustedContact,
ContentType.KeySystemRootKey,
]
}

View File

@@ -0,0 +1,19 @@
export type PersistentSignatureData =
| {
required: true
contentHash: string
result: {
passes: boolean
publicKey: string
signature: string
}
}
| {
required: false
contentHash: string
result?: {
passes: boolean
publicKey: string
signature: string
}
}