feat: add models package

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

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ packages/filepicker/dist
packages/features/dist
packages/encryption/dist
packages/files/dist
packages/models/dist
**/.pnp.*
**/.yarn/*

View File

@@ -39,7 +39,7 @@
},
"dependencies": {
"@standardnotes/common": "^1.23.1",
"@standardnotes/models": "^1.11.13",
"@standardnotes/models": "workspace:*",
"@standardnotes/responses": "^1.6.39",
"@standardnotes/services": "^1.13.23",
"@standardnotes/sncrypto-common": "^1.9.0",

View File

@@ -35,7 +35,7 @@
"@standardnotes/common": "^1.23.1",
"@standardnotes/encryption": "workspace:*",
"@standardnotes/filepicker": "workspace:*",
"@standardnotes/models": "^1.11.13",
"@standardnotes/models": "workspace:*",
"@standardnotes/responses": "^1.6.39",
"@standardnotes/services": "^1.13.23",
"@standardnotes/sncrypto-common": "^1.9.0",

View File

@@ -0,0 +1,2 @@
node_modules
dist

10
packages/models/.eslintrc Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "./linter.tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": true }],
"@typescript-eslint/no-non-null-assertion": "warn"
}
}

View File

@@ -0,0 +1,314 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.12.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.14...@standardnotes/models@1.12.0) (2022-07-05)
### Features
* remove features package in favor of standardnotes/app repository ([bb8226b](https://github.com/standardnotes/snjs/commit/bb8226b77550707c2a981778a78fe3dccf1aaa03))
## [1.11.14](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.13...@standardnotes/models@1.11.14) (2022-07-04)
### Bug Fixes
* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b))
## [1.11.13](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.12...@standardnotes/models@1.11.13) (2022-06-29)
**Note:** Version bump only for package @standardnotes/models
## [1.11.12](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.11...@standardnotes/models@1.11.12) (2022-06-27)
**Note:** Version bump only for package @standardnotes/models
## [1.11.11](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.10...@standardnotes/models@1.11.11) (2022-06-27)
**Note:** Version bump only for package @standardnotes/models
## [1.11.10](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.9...@standardnotes/models@1.11.10) (2022-06-16)
**Note:** Version bump only for package @standardnotes/models
## [1.11.9](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.8...@standardnotes/models@1.11.9) (2022-06-16)
**Note:** Version bump only for package @standardnotes/models
## [1.11.8](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.7...@standardnotes/models@1.11.8) (2022-06-15)
**Note:** Version bump only for package @standardnotes/models
## [1.11.7](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.6...@standardnotes/models@1.11.7) (2022-06-10)
**Note:** Version bump only for package @standardnotes/models
## [1.11.6](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.5...@standardnotes/models@1.11.6) (2022-06-09)
**Note:** Version bump only for package @standardnotes/models
## [1.11.5](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.4...@standardnotes/models@1.11.5) (2022-06-09)
**Note:** Version bump only for package @standardnotes/models
## [1.11.4](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.3...@standardnotes/models@1.11.4) (2022-06-06)
### Bug Fixes
* reverse title sort ([#757](https://github.com/standardnotes/snjs/issues/757)) ([dacee77](https://github.com/standardnotes/snjs/commit/dacee77488593ec71c670c1bfa62cc7f526c8b56))
## [1.11.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.2...@standardnotes/models@1.11.3) (2022-06-03)
### Bug Fixes
* define getters on items used in predicates so keypath lookups are not undefined ([#756](https://github.com/standardnotes/snjs/issues/756)) ([3297077](https://github.com/standardnotes/snjs/commit/32970774897a48fd9a12b329ca204ed6882a47ab))
## [1.11.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.1...@standardnotes/models@1.11.2) (2022-06-02)
**Note:** Version bump only for package @standardnotes/models
## [1.11.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.11.0...@standardnotes/models@1.11.1) (2022-05-30)
**Note:** Version bump only for package @standardnotes/models
# [1.11.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.10.3...@standardnotes/models@1.11.0) (2022-05-27)
### Features
* add 'name' and 'offlineOnly' setters to ComponentMutator ([#751](https://github.com/standardnotes/snjs/issues/751)) ([55b1f68](https://github.com/standardnotes/snjs/commit/55b1f687fb25facf925b081871152e4ea7723886))
## [1.10.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.10.2...@standardnotes/models@1.10.3) (2022-05-27)
**Note:** Version bump only for package @standardnotes/models
## [1.10.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.10.1...@standardnotes/models@1.10.2) (2022-05-24)
**Note:** Version bump only for package @standardnotes/models
## [1.10.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.10.0...@standardnotes/models@1.10.1) (2022-05-24)
**Note:** Version bump only for package @standardnotes/models
# [1.10.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.9.0...@standardnotes/models@1.10.0) (2022-05-22)
### Features
* optional files navigation ([#745](https://github.com/standardnotes/snjs/issues/745)) ([8512166](https://github.com/standardnotes/snjs/commit/851216615478b57b11a570173f94ee598bec31c0))
# [1.9.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.8...@standardnotes/models@1.9.0) (2022-05-21)
### Features
* display controllers ([#743](https://github.com/standardnotes/snjs/issues/743)) ([5fadce3](https://github.com/standardnotes/snjs/commit/5fadce37f1b3f2f51b8a90c257bc666ac7710074))
## [1.8.8](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.7...@standardnotes/models@1.8.8) (2022-05-20)
**Note:** Version bump only for package @standardnotes/models
## [1.8.7](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.6...@standardnotes/models@1.8.7) (2022-05-20)
**Note:** Version bump only for package @standardnotes/models
## [1.8.6](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.5...@standardnotes/models@1.8.6) (2022-05-17)
**Note:** Version bump only for package @standardnotes/models
## [1.8.5](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.4...@standardnotes/models@1.8.5) (2022-05-17)
**Note:** Version bump only for package @standardnotes/models
## [1.8.4](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.3...@standardnotes/models@1.8.4) (2022-05-16)
**Note:** Version bump only for package @standardnotes/models
## [1.8.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.2...@standardnotes/models@1.8.3) (2022-05-16)
**Note:** Version bump only for package @standardnotes/models
## [1.8.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.1...@standardnotes/models@1.8.2) (2022-05-16)
**Note:** Version bump only for package @standardnotes/models
## [1.8.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.8.0...@standardnotes/models@1.8.1) (2022-05-13)
**Note:** Version bump only for package @standardnotes/models
# [1.8.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.7.1...@standardnotes/models@1.8.0) (2022-05-12)
### Features
* file desktop backups ([#731](https://github.com/standardnotes/snjs/issues/731)) ([0dbce7d](https://github.com/standardnotes/snjs/commit/0dbce7dc9712fde848445b951079c81479c8bc11))
## [1.7.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.7.0...@standardnotes/models@1.7.1) (2022-05-12)
**Note:** Version bump only for package @standardnotes/models
# [1.7.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.10...@standardnotes/models@1.7.0) (2022-05-12)
### Features
* new mobile-specific pref keys ([#730](https://github.com/standardnotes/snjs/issues/730)) ([cbf86a3](https://github.com/standardnotes/snjs/commit/cbf86a310e48a238ec8d8a5fd3d5c79da9120bd3))
## [1.6.10](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.9...@standardnotes/models@1.6.10) (2022-05-09)
### Bug Fixes
* no conflict on files ([#728](https://github.com/standardnotes/snjs/issues/728)) ([9d1273d](https://github.com/standardnotes/snjs/commit/9d1273d21b299be826ff996fc97381242c13e8f1))
## [1.6.9](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.8...@standardnotes/models@1.6.9) (2022-05-09)
**Note:** Version bump only for package @standardnotes/models
## [1.6.8](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.7...@standardnotes/models@1.6.8) (2022-05-06)
### Bug Fixes
* update note count after remote delete ([#725](https://github.com/standardnotes/snjs/issues/725)) ([043edce](https://github.com/standardnotes/snjs/commit/043edcea9dfc7a8b234363910791f173880efdb9))
## [1.6.7](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.6...@standardnotes/models@1.6.7) (2022-05-06)
**Note:** Version bump only for package @standardnotes/models
## [1.6.6](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.5...@standardnotes/models@1.6.6) (2022-05-05)
**Note:** Version bump only for package @standardnotes/models
## [1.6.5](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.3...@standardnotes/models@1.6.5) (2022-05-04)
**Note:** Version bump only for package @standardnotes/models
## [1.6.4](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.3...@standardnotes/models@1.6.4) (2022-05-04)
**Note:** Version bump only for package @standardnotes/models
## [1.6.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.2...@standardnotes/models@1.6.3) (2022-05-03)
**Note:** Version bump only for package @standardnotes/models
## [1.6.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.1...@standardnotes/models@1.6.2) (2022-05-02)
**Note:** Version bump only for package @standardnotes/models
## [1.6.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.6.0...@standardnotes/models@1.6.1) (2022-04-28)
**Note:** Version bump only for package @standardnotes/models
# [1.6.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.5.1...@standardnotes/models@1.6.0) (2022-04-27)
### Features
* make files sortable using setDisplayOptions ([#713](https://github.com/standardnotes/snjs/issues/713)) ([b2088bf](https://github.com/standardnotes/snjs/commit/b2088bfa169ddea9aeddf9dfb20a098991aed875))
## [1.5.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.5.0...@standardnotes/models@1.5.1) (2022-04-27)
**Note:** Version bump only for package @standardnotes/models
# [1.5.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.9...@standardnotes/models@1.5.0) (2022-04-27)
### Features
* file upload and download progress ([#711](https://github.com/standardnotes/snjs/issues/711)) ([79fceed](https://github.com/standardnotes/snjs/commit/79fceeda4066dc66142f18c9c7b110757ca67e69))
## [1.4.9](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.8...@standardnotes/models@1.4.9) (2022-04-25)
**Note:** Version bump only for package @standardnotes/models
## [1.4.8](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.7...@standardnotes/models@1.4.8) (2022-04-22)
**Note:** Version bump only for package @standardnotes/models
## [1.4.7](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.6...@standardnotes/models@1.4.7) (2022-04-21)
### Bug Fixes
* abort key recovery after aborted challenge ([#703](https://github.com/standardnotes/snjs/issues/703)) ([a67fb7e](https://github.com/standardnotes/snjs/commit/a67fb7e8cde41a5c9fadf545933e35d525faeaf0))
## [1.4.6](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.5...@standardnotes/models@1.4.6) (2022-04-20)
**Note:** Version bump only for package @standardnotes/models
## [1.4.5](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.4...@standardnotes/models@1.4.5) (2022-04-20)
**Note:** Version bump only for package @standardnotes/models
## [1.4.4](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.3...@standardnotes/models@1.4.4) (2022-04-19)
### Bug Fixes
* better conflict errored items ([#699](https://github.com/standardnotes/snjs/issues/699)) ([1feaddd](https://github.com/standardnotes/snjs/commit/1feadddb79a4b39d08b6de979a380984fec6c689))
## [1.4.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.2...@standardnotes/models@1.4.3) (2022-04-19)
### Bug Fixes
* properly handle encrypted item changes in collections ([#698](https://github.com/standardnotes/snjs/issues/698)) ([8b23c65](https://github.com/standardnotes/snjs/commit/8b23c6555decbdc5099fc4228ff889f7e5c8eb85))
## [1.4.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.1...@standardnotes/models@1.4.2) (2022-04-19)
**Note:** Version bump only for package @standardnotes/models
## [1.4.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.4.0...@standardnotes/models@1.4.1) (2022-04-18)
### Bug Fixes
* make timestamps required in payload construction ([#695](https://github.com/standardnotes/snjs/issues/695)) ([b3326c0](https://github.com/standardnotes/snjs/commit/b3326c0a998cd9d44a76afc377f182885ef48275))
# [1.4.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.3.0...@standardnotes/models@1.4.0) (2022-04-15)
### Features
* introduce sync resolved payloads to ensure deltas always return up to date dirty state ([#694](https://github.com/standardnotes/snjs/issues/694)) ([e5278ba](https://github.com/standardnotes/snjs/commit/e5278ba0b2afa987c37f009a2101fb91949d44c6))
# [1.3.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.6...@standardnotes/models@1.3.0) (2022-04-15)
### Features
* no merge payloads in payload manager ([#693](https://github.com/standardnotes/snjs/issues/693)) ([68a577c](https://github.com/standardnotes/snjs/commit/68a577cb887fd2d5556dc9ddec461f6ae665fcb6))
## [1.2.6](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.5...@standardnotes/models@1.2.6) (2022-04-15)
**Note:** Version bump only for package @standardnotes/models
## [1.2.5](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.4...@standardnotes/models@1.2.5) (2022-04-14)
### Bug Fixes
* map ignored item timestamps so application remains in sync ([#692](https://github.com/standardnotes/snjs/issues/692)) ([966cbb0](https://github.com/standardnotes/snjs/commit/966cbb0c254d4d95c802bd8951488a499d1f8bef))
## [1.2.4](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.3...@standardnotes/models@1.2.4) (2022-04-13)
### Bug Fixes
* emit changed deleted items as removed ([#691](https://github.com/standardnotes/snjs/issues/691)) ([b12f257](https://github.com/standardnotes/snjs/commit/b12f257b02d46ad9c717e6c51d6e7ca7e9c06959))
## [1.2.3](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.2...@standardnotes/models@1.2.3) (2022-04-12)
**Note:** Version bump only for package @standardnotes/models
## [1.2.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.1...@standardnotes/models@1.2.2) (2022-04-11)
**Note:** Version bump only for package @standardnotes/models
## [1.2.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.2.0...@standardnotes/models@1.2.1) (2022-04-01)
**Note:** Version bump only for package @standardnotes/models
# [1.2.0](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.1.2...@standardnotes/models@1.2.0) (2022-04-01)
### Features
* content interfaces and model type strictness ([#685](https://github.com/standardnotes/snjs/issues/685)) ([e2450c5](https://github.com/standardnotes/snjs/commit/e2450c59e8309d7080efaa03905b2abc728d9403))
## [1.1.2](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.1.1...@standardnotes/models@1.1.2) (2022-04-01)
**Note:** Version bump only for package @standardnotes/models
## [1.1.1](https://github.com/standardnotes/snjs/compare/@standardnotes/models@1.1.0...@standardnotes/models@1.1.1) (2022-03-31)
**Note:** Version bump only for package @standardnotes/models
# 1.1.0 (2022-03-31)
### Features
* encryption and models packages ([#679](https://github.com/standardnotes/snjs/issues/679)) ([5e03d48](https://github.com/standardnotes/snjs/commit/5e03d48aba7e3dd266117201139ab869b1f70cc9))

View File

@@ -0,0 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const base = require('../../node_modules/@standardnotes/config/src/jest.json');
module.exports = {
...base,
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
}
};

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist"]
}

View File

@@ -0,0 +1,43 @@
{
"name": "@standardnotes/models",
"version": "1.13.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
"description": "Models used in SNJS library",
"main": "dist/index.js",
"author": "Standard Notes",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"license": "AGPL-3.0-or-later",
"scripts": {
"clean": "rm -fr dist",
"prestart": "yarn clean",
"start": "tsc -p tsconfig.json --watch",
"prebuild": "yarn clean",
"build": "tsc -p tsconfig.json",
"lint": "eslint . --ext .ts",
"test:unit": "jest"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.182",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^27.5.1",
"ts-jest": "^27.1.3"
},
"dependencies": {
"@standardnotes/common": "^1.23.1",
"@standardnotes/features": "workspace:*",
"@standardnotes/responses": "^1.6.39",
"@standardnotes/utils": "^1.6.12",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13"
}
}

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export enum ConflictStrategy {
KeepBase = 1,
KeepApply = 2,
KeepBaseDuplicateApply = 3,
DuplicateBaseKeepApply = 4,
KeepBaseMergeRefs = 5,
}

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export enum SingletonStrategy {
KeepEarliest = 1,
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,8 @@
import { ContentType } from '@standardnotes/common'
import { ContenteReferenceType } from './ContenteReferenceType'
export interface AnonymousReference {
uuid: string
content_type: ContentType
reference_type: ContenteReferenceType
}

View File

@@ -0,0 +1,4 @@
import { LegacyAnonymousReference } from './LegacyAnonymousReference'
import { Reference } from './Reference'
export type ContentReference = LegacyAnonymousReference | Reference

View File

@@ -0,0 +1,5 @@
export enum ContenteReferenceType {
TagToParentTag = 'TagToParentTag',
FileToNote = 'FileToNote',
TagToFile = 'TagToFile',
}

View File

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

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

View File

@@ -0,0 +1,4 @@
export interface LegacyAnonymousReference {
uuid: string
content_type: string
}

View File

@@ -0,0 +1,6 @@
import { ContentType } from '@standardnotes/common'
import { LegacyAnonymousReference } from './LegacyAnonymousReference'
export interface LegacyTagToNoteReference extends LegacyAnonymousReference {
content_type: ContentType.Note
}

View File

@@ -0,0 +1,3 @@
import { TagToParentTagReference } from './TagToParentTagReference'
export type Reference = TagToParentTagReference

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { ItemContent } from '../../Content/ItemContent'
import { TransferPayload } from './TransferPayload'
export interface DecryptedTransferPayload<C extends ItemContent = ItemContent> extends TransferPayload {
content: C
}

View File

@@ -0,0 +1,6 @@
import { TransferPayload } from './TransferPayload'
export interface DeletedTransferPayload extends TransferPayload {
content: undefined
deleted: true
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export * from './Interfaces/DecryptedTransferPayload'
export * from './Interfaces/DeletedTransferPayload'
export * from './Interfaces/EncryptedTransferPayload'
export * from './Interfaces/TransferPayload'
export * from './Interfaces/TypeCheck'

View File

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

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More