diff --git a/.gitignore b/.gitignore index fab97a001..411b3e349 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ packages/filepicker/dist packages/features/dist packages/encryption/dist packages/files/dist +packages/models/dist **/.pnp.* **/.yarn/* diff --git a/.yarn/cache/@standardnotes-models-npm-1.11.12-d07d5ebeb6-636897db97.zip b/.yarn/cache/@standardnotes-models-npm-1.11.12-d07d5ebeb6-636897db97.zip deleted file mode 100644 index 70a5209e3..000000000 Binary files a/.yarn/cache/@standardnotes-models-npm-1.11.12-d07d5ebeb6-636897db97.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-models-npm-1.11.13-8272aa4de5-063f4382b8.zip b/.yarn/cache/@standardnotes-models-npm-1.11.13-8272aa4de5-063f4382b8.zip deleted file mode 100644 index a96f6c1ff..000000000 Binary files a/.yarn/cache/@standardnotes-models-npm-1.11.13-8272aa4de5-063f4382b8.zip and /dev/null differ diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 0419f1858..a88b8c82b 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -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", diff --git a/packages/files/package.json b/packages/files/package.json index 3cc850f5c..152b1017a 100644 --- a/packages/files/package.json +++ b/packages/files/package.json @@ -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", diff --git a/packages/models/.eslintignore b/packages/models/.eslintignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/models/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/models/.eslintrc b/packages/models/.eslintrc new file mode 100644 index 000000000..86b280b2c --- /dev/null +++ b/packages/models/.eslintrc @@ -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" + } +} diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md new file mode 100644 index 000000000..f46e834ce --- /dev/null +++ b/packages/models/CHANGELOG.md @@ -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)) diff --git a/packages/models/jest.config.js b/packages/models/jest.config.js new file mode 100644 index 000000000..ad1ceabb0 --- /dev/null +++ b/packages/models/jest.config.js @@ -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', + }, + } +}; diff --git a/packages/models/linter.tsconfig.json b/packages/models/linter.tsconfig.json new file mode 100644 index 000000000..c1a7d22c5 --- /dev/null +++ b/packages/models/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist"] +} diff --git a/packages/models/package.json b/packages/models/package.json new file mode 100644 index 000000000..573c4be0d --- /dev/null +++ b/packages/models/package.json @@ -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" + } +} diff --git a/packages/models/src/Domain/Abstract/Content/ItemContent.ts b/packages/models/src/Domain/Abstract/Content/ItemContent.ts new file mode 100644 index 000000000..c8d35f700 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Content/ItemContent.ts @@ -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(content: Partial): 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( + content: S, +): C { + return FillItemContent(content) +} diff --git a/packages/models/src/Domain/Abstract/Contextual/BackupFile.ts b/packages/models/src/Domain/Abstract/Contextual/BackupFile.ts new file mode 100644 index 000000000..87b4bc6be --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/BackupFile.ts @@ -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 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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/ComponentCreate.ts b/packages/models/src/Domain/Abstract/Contextual/ComponentCreate.ts new file mode 100644 index 000000000..f44589dc9 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/ComponentCreate.ts @@ -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 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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/ComponentRetrieved.ts b/packages/models/src/Domain/Abstract/Contextual/ComponentRetrieved.ts new file mode 100644 index 000000000..a6d7baf35 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/ComponentRetrieved.ts @@ -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 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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts b/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts new file mode 100644 index 000000000..a3cd11887 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts @@ -0,0 +1,9 @@ +import { ContentType } from '@standardnotes/common' +import { ItemContent } from '../Content/ItemContent' + +export interface ContextPayload { + uuid: string + content_type: ContentType + content: C | string | undefined + deleted: boolean +} diff --git a/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts b/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts new file mode 100644 index 000000000..54c038db7 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts b/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts new file mode 100644 index 000000000..d37239301 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts @@ -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 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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/OfflineSyncPush.ts b/packages/models/src/Domain/Abstract/Contextual/OfflineSyncPush.ts new file mode 100644 index 000000000..e358425a9 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/OfflineSyncPush.ts @@ -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, + } + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/OfflineSyncSaved.ts b/packages/models/src/Domain/Abstract/Contextual/OfflineSyncSaved.ts new file mode 100644 index 000000000..e6d9b64aa --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/OfflineSyncSaved.ts @@ -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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/ServerSyncPush.ts b/packages/models/src/Domain/Abstract/Contextual/ServerSyncPush.ts new file mode 100644 index 000000000..6f8c7801f --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/ServerSyncPush.ts @@ -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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/ServerSyncSaved.ts b/packages/models/src/Domain/Abstract/Contextual/ServerSyncSaved.ts new file mode 100644 index 000000000..d77284615 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/ServerSyncSaved.ts @@ -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, + } +} diff --git a/packages/models/src/Domain/Abstract/Contextual/SessionHistory.ts b/packages/models/src/Domain/Abstract/Contextual/SessionHistory.ts new file mode 100644 index 000000000..0de13f34d --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/SessionHistory.ts @@ -0,0 +1,7 @@ +import { ItemContent } from '../Content/ItemContent' +import { ContextPayload } from './ContextPayload' + +export interface SessionHistoryContextualPayload extends ContextPayload { + content: C + updated_at: Date +} diff --git a/packages/models/src/Domain/Abstract/Contextual/index.ts b/packages/models/src/Domain/Abstract/Contextual/index.ts new file mode 100644 index 000000000..b6a0ab3e6 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Contextual/index.ts @@ -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' diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts new file mode 100644 index 000000000..21740cd11 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts @@ -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 + extends GenericItem> + implements DecryptedItemInterface +{ + 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) { + 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. + * + * 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 { + const domainData = this.payload.content.appData + if (!domainData) { + return undefined + } + const data = domainData[domain] + return data + } + + public getAppDomainValue(key: AppDataField | PrefKey): T | undefined { + const appData = this.getDomainData(DefaultAppDomain) + return appData?.[key] as T + } + + public getAppDomainValueWithDefault(key: AppDataField | PrefKey, defaultValue: D): T { + const appData = this.getDomainData(DefaultAppDomain) + return (appData?.[key] as T) || defaultValue + } + + public override payloadRepresentation(override?: Partial>): DecryptedPayloadInterface { + 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(): (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(), + ) + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/DeletedItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/DeletedItem.ts new file mode 100644 index 000000000..383a5cfd5 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Implementations/DeletedItem.ts @@ -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 implements DeletedItemInterface { + deleted: true + content: undefined + + constructor(payload: DeletedPayloadInterface) { + super(payload) + this.deleted = true + } + + public override payloadRepresentation(override?: Partial): DeletedPayloadInterface { + return this.payload.copy(override) + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/EncryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/EncryptedItem.ts new file mode 100644 index 000000000..97a76ac3f --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Implementations/EncryptedItem.ts @@ -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 implements EncryptedItemInterface { + constructor(payload: EncryptedPayloadInterface) { + super(payload) + } + + get version() { + return this.payload.version + } + + public override payloadRepresentation(override?: Partial): 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 + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts new file mode 100644 index 000000000..42a1c7b72 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts @@ -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

implements ItemInterface

{ + 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): 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(): PredicateInterface { + 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): boolean { + return predicate.matchesItem(this) + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts new file mode 100644 index 000000000..7bd838f6b --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts @@ -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 + extends ItemInterface>, + 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(key: AppDataField | PrefKey, defaultValue: D): T + + getAppDomainValue(key: AppDataField | PrefKey): T | undefined + + isItemContentEqualWith(otherItem: DecryptedItemInterface): boolean + + payloadRepresentation(override?: Partial>): DecryptedPayloadInterface + + isReferencingItem(item: DecryptedItemInterface): boolean + + getDomainData(domain: typeof ComponentDataDomain | typeof DefaultAppDomain): undefined | Record + + contentKeysToIgnoreWhenCheckingEquality(): (keyof C)[] + + appDataContentKeysToIgnoreWhenCheckingEquality(): AppDataField[] + + getContentCopy(): C +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/DeletedItem.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/DeletedItem.ts new file mode 100644 index 000000000..f6d6d2010 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/DeletedItem.ts @@ -0,0 +1,7 @@ +import { DeletedPayloadInterface } from './../../Payload/Interfaces/DeletedPayload' +import { ItemInterface } from './ItemInterface' + +export interface DeletedItemInterface extends ItemInterface { + readonly deleted: true + readonly content: undefined +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/EncryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/EncryptedItem.ts new file mode 100644 index 000000000..5c3c3071e --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/EncryptedItem.ts @@ -0,0 +1,11 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { EncryptedPayloadInterface } from '../../Payload/Interfaces/EncryptedPayload' +import { ItemInterface } from './ItemInterface' + +export interface EncryptedItemInterface extends ItemInterface { + content: string + version: ProtocolVersion + errorDecrypting: boolean + waitingForKey?: boolean + auth_hash?: string +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts new file mode 100644 index 000000000..d6087b83c --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts @@ -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

{ + 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(): PredicateInterface + + singletonStrategy: SingletonStrategy + + strategyWhenConflictingWithItem(item: ItemInterface, previousRevision?: HistoryEntryInterface): ConflictStrategy + + satisfiesPredicate(predicate: PredicateInterface): boolean + + payloadRepresentation(override?: Partial): P +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts new file mode 100644 index 000000000..e2d3cec27 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/TypeCheck.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/UnionTypes.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/UnionTypes.ts new file mode 100644 index 000000000..13332c525 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/UnionTypes.ts @@ -0,0 +1,9 @@ +import { ItemContent } from '../../Content/ItemContent' +import { DecryptedItemInterface } from './DecryptedItem' +import { DeletedItemInterface } from './DeletedItem' +import { EncryptedItemInterface } from './EncryptedItem' + +export type AnyItemInterface = + | EncryptedItemInterface + | DecryptedItemInterface + | DeletedItemInterface diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts new file mode 100644 index 000000000..f6b3e7610 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts @@ -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 extends ItemMutator< + DecryptedPayloadInterface, + DecryptedItemInterface +> { + protected mutableContent: C + + constructor(item: DecryptedItemInterface, 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 + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/DeleteMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/DeleteMutator.ts new file mode 100644 index 000000000..df0ff6d2d --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Mutator/DeleteMutator.ts @@ -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 = ItemInterface, +> extends ItemMutator { + 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') + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts new file mode 100644 index 000000000..2214a9d70 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts @@ -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 = PayloadInterface, + I extends ItemInterface

= ItemInterface

, +> { + 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') + } +} diff --git a/packages/models/src/Domain/Abstract/Item/Types/AppDataField.ts b/packages/models/src/Domain/Abstract/Item/Types/AppDataField.ts new file mode 100644 index 000000000..ea530b0c3 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Types/AppDataField.ts @@ -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', +} diff --git a/packages/models/src/Domain/Abstract/Item/Types/ConflictStrategy.ts b/packages/models/src/Domain/Abstract/Item/Types/ConflictStrategy.ts new file mode 100644 index 000000000..4c711b8e2 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Types/ConflictStrategy.ts @@ -0,0 +1,7 @@ +export enum ConflictStrategy { + KeepBase = 1, + KeepApply = 2, + KeepBaseDuplicateApply = 3, + DuplicateBaseKeepApply = 4, + KeepBaseMergeRefs = 5, +} diff --git a/packages/models/src/Domain/Abstract/Item/Types/DefaultAppDomain.ts b/packages/models/src/Domain/Abstract/Item/Types/DefaultAppDomain.ts new file mode 100644 index 000000000..9382ca2ae --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Types/DefaultAppDomain.ts @@ -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> +export type ComponentDomainValueType = Record +export type DomainDataValueType = AppDomainValueType | ComponentDomainValueType + +export type AppData = { + [DefaultAppDomain]: AppDomainValueType + [ComponentDataDomain]?: ComponentDomainValueType +} diff --git a/packages/models/src/Domain/Abstract/Item/Types/MutationType.ts b/packages/models/src/Domain/Abstract/Item/Types/MutationType.ts new file mode 100644 index 000000000..ba84e9623 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Types/MutationType.ts @@ -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, +} diff --git a/packages/models/src/Domain/Abstract/Item/Types/SingletonStrategy.ts b/packages/models/src/Domain/Abstract/Item/Types/SingletonStrategy.ts new file mode 100644 index 000000000..7721db226 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/Types/SingletonStrategy.ts @@ -0,0 +1,3 @@ +export enum SingletonStrategy { + KeepEarliest = 1, +} diff --git a/packages/models/src/Domain/Abstract/Item/index.ts b/packages/models/src/Domain/Abstract/Item/index.ts new file mode 100644 index 000000000..99509324b --- /dev/null +++ b/packages/models/src/Domain/Abstract/Item/index.ts @@ -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' diff --git a/packages/models/src/Domain/Abstract/Payload/Implementations/DecryptedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Implementations/DecryptedPayload.ts new file mode 100644 index 000000000..da52b7e96 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Implementations/DecryptedPayload.ts @@ -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 = DecryptedTransferPayload, + > + extends PurePayload + implements DecryptedPayloadInterface +{ + override readonly content: C + override readonly deleted: false + + constructor(rawPayload: T, source = PayloadSource.Constructor) { + super(rawPayload, source) + + this.content = Copy(FillItemContent(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 { + return { + ...super.ejected(), + content: this.content, + deleted: this.deleted, + } + } + + copy(override?: Partial, source = this.source): this { + const result = new DecryptedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as this + } + + copyAsSyncResolved(override?: Partial & SyncResolvedParams, source = this.source): SyncResolvedPayload { + const result = new DecryptedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as SyncResolvedPayload + } +} diff --git a/packages/models/src/Domain/Abstract/Payload/Implementations/DeletedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Implementations/DeletedPayload.ts new file mode 100644 index 000000000..9ba4e988a --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Implementations/DeletedPayload.ts @@ -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 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, source = this.source): this { + const result = new DeletedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as this + } + + copyAsSyncResolved( + override?: Partial & SyncResolvedParams, + source = this.source, + ): SyncResolvedPayload { + const result = new DeletedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as SyncResolvedPayload + } +} diff --git a/packages/models/src/Domain/Abstract/Payload/Implementations/EncryptedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Implementations/EncryptedPayload.ts new file mode 100644 index 000000000..1bf32260d --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Implementations/EncryptedPayload.ts @@ -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 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, source = this.source): this { + const result = new EncryptedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as this + } + + copyAsSyncResolved( + override?: Partial & SyncResolvedParams, + source = this.source, + ): SyncResolvedPayload { + const result = new EncryptedPayload( + { + ...this.ejected(), + ...override, + }, + source, + ) + return result as SyncResolvedPayload + } +} diff --git a/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts b/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts new file mode 100644 index 000000000..ccd5a6830 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts @@ -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 = { [K in keyof T]-?: [T[K]] } extends infer U + ? U extends Record + ? { [K in keyof U]: U[K][0] } + : never + : never + +export abstract class PurePayload, C extends ItemContent = ItemContent> + implements PayloadInterface +{ + 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 = { + 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, source?: PayloadSource): this + + abstract copyAsSyncResolved(override?: Partial & SyncResolvedParams, source?: PayloadSource): SyncResolvedPayload +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/DecryptedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/DecryptedPayload.ts new file mode 100644 index 000000000..28bde92c7 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/DecryptedPayload.ts @@ -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 + extends PayloadInterface { + readonly content: C + deleted: false + + ejected(): DecryptedTransferPayload + get references(): ContentReference[] + getReference(uuid: Uuid): ContentReference +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/DeletedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/DeletedPayload.ts new file mode 100644 index 000000000..60c228954 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/DeletedPayload.ts @@ -0,0 +1,15 @@ +import { DeletedTransferPayload } from '../../TransferPayload' +import { PayloadInterface } from './PayloadInterface' + +export interface DeletedPayloadInterface extends PayloadInterface { + 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 +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/EncryptedPayload.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/EncryptedPayload.ts new file mode 100644 index 000000000..33c86621e --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/EncryptedPayload.ts @@ -0,0 +1,18 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { EncryptedTransferPayload } from '../../TransferPayload/Interfaces/EncryptedTransferPayload' +import { PayloadInterface } from './PayloadInterface' + +export interface EncryptedPayloadInterface extends PayloadInterface { + 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 +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts new file mode 100644 index 000000000..73bf6a4d0 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts @@ -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 { + 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, source?: PayloadSource): this + + copyAsSyncResolved(override?: Partial & SyncResolvedParams, source?: PayloadSource): SyncResolvedPayload +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/TypeCheck.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/TypeCheck.ts new file mode 100644 index 000000000..c2d941656 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/TypeCheck.ts @@ -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( + payload: PayloadInterface, +): payload is DecryptedPayloadInterface { + 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) +} diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/UnionTypes.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/UnionTypes.ts new file mode 100644 index 000000000..541e29ab8 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/UnionTypes.ts @@ -0,0 +1,11 @@ +import { ItemContent } from '../../Content/ItemContent' +import { DecryptedPayloadInterface } from './DecryptedPayload' +import { DeletedPayloadInterface } from './DeletedPayload' +import { EncryptedPayloadInterface } from './EncryptedPayload' + +export type FullyFormedPayloadInterface = + | DecryptedPayloadInterface + | EncryptedPayloadInterface + | DeletedPayloadInterface + +export type AnyNonDecryptedPayloadInterface = EncryptedPayloadInterface | DeletedPayloadInterface diff --git a/packages/models/src/Domain/Abstract/Payload/Types/EmitSource.ts b/packages/models/src/Domain/Abstract/Payload/Types/EmitSource.ts new file mode 100644 index 000000000..b0a6607d7 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Types/EmitSource.ts @@ -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) +} diff --git a/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts b/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts new file mode 100644 index 000000000..4177073db --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts @@ -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, +} diff --git a/packages/models/src/Domain/Abstract/Payload/Types/TimestampDefaults.ts b/packages/models/src/Domain/Abstract/Payload/Types/TimestampDefaults.ts new file mode 100644 index 000000000..52a4aaf5b --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/Types/TimestampDefaults.ts @@ -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, + } +} diff --git a/packages/models/src/Domain/Abstract/Payload/index.ts b/packages/models/src/Domain/Abstract/Payload/index.ts new file mode 100644 index 000000000..50604221f --- /dev/null +++ b/packages/models/src/Domain/Abstract/Payload/index.ts @@ -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' diff --git a/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts b/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts new file mode 100644 index 000000000..962f4f52f --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts @@ -0,0 +1,8 @@ +import { ContentType } from '@standardnotes/common' +import { ContenteReferenceType } from './ContenteReferenceType' + +export interface AnonymousReference { + uuid: string + content_type: ContentType + reference_type: ContenteReferenceType +} diff --git a/packages/models/src/Domain/Abstract/Reference/ContentReference.ts b/packages/models/src/Domain/Abstract/Reference/ContentReference.ts new file mode 100644 index 000000000..40401e3d0 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/ContentReference.ts @@ -0,0 +1,4 @@ +import { LegacyAnonymousReference } from './LegacyAnonymousReference' +import { Reference } from './Reference' + +export type ContentReference = LegacyAnonymousReference | Reference diff --git a/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts b/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts new file mode 100644 index 000000000..801a777bc --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts @@ -0,0 +1,5 @@ +export enum ContenteReferenceType { + TagToParentTag = 'TagToParentTag', + FileToNote = 'FileToNote', + TagToFile = 'TagToFile', +} diff --git a/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts b/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts new file mode 100644 index 000000000..34385f55f --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/Reference/Functions.ts b/packages/models/src/Domain/Abstract/Reference/Functions.ts new file mode 100644 index 000000000..d5d8edd72 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/Functions.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/Reference/LegacyAnonymousReference.ts b/packages/models/src/Domain/Abstract/Reference/LegacyAnonymousReference.ts new file mode 100644 index 000000000..5d445eac1 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/LegacyAnonymousReference.ts @@ -0,0 +1,4 @@ +export interface LegacyAnonymousReference { + uuid: string + content_type: string +} diff --git a/packages/models/src/Domain/Abstract/Reference/LegacyTagToNoteReference.ts b/packages/models/src/Domain/Abstract/Reference/LegacyTagToNoteReference.ts new file mode 100644 index 000000000..61a47284c --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/LegacyTagToNoteReference.ts @@ -0,0 +1,6 @@ +import { ContentType } from '@standardnotes/common' +import { LegacyAnonymousReference } from './LegacyAnonymousReference' + +export interface LegacyTagToNoteReference extends LegacyAnonymousReference { + content_type: ContentType.Note +} diff --git a/packages/models/src/Domain/Abstract/Reference/Reference.ts b/packages/models/src/Domain/Abstract/Reference/Reference.ts new file mode 100644 index 000000000..2dc74358e --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/Reference.ts @@ -0,0 +1,3 @@ +import { TagToParentTagReference } from './TagToParentTagReference' + +export type Reference = TagToParentTagReference diff --git a/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts b/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts new file mode 100644 index 000000000..9662f4c6a --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts b/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts new file mode 100644 index 000000000..96be8f144 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DecryptedTransferPayload.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DecryptedTransferPayload.ts new file mode 100644 index 000000000..b850d9710 --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DecryptedTransferPayload.ts @@ -0,0 +1,6 @@ +import { ItemContent } from '../../Content/ItemContent' +import { TransferPayload } from './TransferPayload' + +export interface DecryptedTransferPayload extends TransferPayload { + content: C +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DeletedTransferPayload.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DeletedTransferPayload.ts new file mode 100644 index 000000000..e2b3626dc --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/DeletedTransferPayload.ts @@ -0,0 +1,6 @@ +import { TransferPayload } from './TransferPayload' + +export interface DeletedTransferPayload extends TransferPayload { + content: undefined + deleted: true +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/EncryptedTransferPayload.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/EncryptedTransferPayload.ts new file mode 100644 index 000000000..314b9e26d --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/EncryptedTransferPayload.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts new file mode 100644 index 000000000..9ff995232 --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts @@ -0,0 +1,23 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { ItemContent } from '../../Content/ItemContent' + +export interface TransferPayload { + 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 +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TypeCheck.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TypeCheck.ts new file mode 100644 index 000000000..2c53b8789 --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TypeCheck.ts @@ -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 +} diff --git a/packages/models/src/Domain/Abstract/TransferPayload/index.ts b/packages/models/src/Domain/Abstract/TransferPayload/index.ts new file mode 100644 index 000000000..638d4db1b --- /dev/null +++ b/packages/models/src/Domain/Abstract/TransferPayload/index.ts @@ -0,0 +1,5 @@ +export * from './Interfaces/DecryptedTransferPayload' +export * from './Interfaces/DeletedTransferPayload' +export * from './Interfaces/EncryptedTransferPayload' +export * from './Interfaces/TransferPayload' +export * from './Interfaces/TypeCheck' diff --git a/packages/models/src/Domain/Local/KeyParams/RootKeyParamsInterface.ts b/packages/models/src/Domain/Local/KeyParams/RootKeyParamsInterface.ts new file mode 100644 index 000000000..03668ab59 --- /dev/null +++ b/packages/models/src/Domain/Local/KeyParams/RootKeyParamsInterface.ts @@ -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 +} diff --git a/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts b/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts new file mode 100644 index 000000000..9e5a12bd7 --- /dev/null +++ b/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts @@ -0,0 +1,31 @@ +import { ApplicationIdentifier, ProtocolVersion } from '@standardnotes/common' +import { RootKeyContentSpecialized } from './RootKeyContent' + +export type RawKeychainValue = Record + +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 +} diff --git a/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts b/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts new file mode 100644 index 000000000..f4f1c56c7 --- /dev/null +++ b/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts @@ -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 diff --git a/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts b/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts new file mode 100644 index 000000000..cae177525 --- /dev/null +++ b/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts @@ -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 { + 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 +} diff --git a/packages/models/src/Domain/Runtime/Collection/Collection.ts b/packages/models/src/Domain/Runtime/Collection/Collection.ts new file mode 100644 index 000000000..eb7f61ef3 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Collection.ts @@ -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 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> = {} + readonly typedMap: Partial> = {} + + /** An array of uuids of items that are dirty */ + dirtyIndex: Set = new Set() + + /** An array of uuids of items that are not marked as deleted */ + nondeletedIndex: Set = new Set() + + /** An array of uuids of items that are errorDecrypting or waitingForKey */ + invalidsIndex: Set = 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>, + typedMapCopy?: Partial>, + 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(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 + } +} diff --git a/packages/models/src/Domain/Runtime/Collection/CollectionInterface.ts b/packages/models/src/Domain/Runtime/Collection/CollectionInterface.ts new file mode 100644 index 000000000..8f025982e --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/CollectionInterface.ts @@ -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 +} diff --git a/packages/models/src/Domain/Runtime/Collection/CollectionSort.ts b/packages/models/src/Domain/Runtime/Collection/CollectionSort.ts new file mode 100644 index 000000000..166012dcc --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/CollectionSort.ts @@ -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 = { + CreatedAt: 'created_at', + UpdatedAt: 'userModifiedDate', + Title: 'title', +} + +export type CollectionSortDirection = 'asc' | 'dsc' + +export type CollectionSortProperty = keyof SortableItem diff --git a/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.spec.ts b/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.spec.ts new file mode 100644 index 000000000..ae84e33d5 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.spec.ts @@ -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({ + 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, + }), + ) + + collection.set(updatedItem) + + expect(collection.all()).toHaveLength(1) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.ts b/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.ts new file mode 100644 index 000000000..8a553d3d4 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Item/ItemCollection.ts @@ -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 + 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(uuid: Uuid): T | undefined { + const result = this.find(uuid) + + if (!result) { + return undefined + } + + return isDecryptedItem(result) ? (result as T) : undefined + } + + public findAllDecrypted(uuids: Uuid[]): T[] { + return this.findAll(uuids).filter(isDecryptedItem) as T[] + } + + public findAllDecryptedWithBlanks( + uuids: Uuid[], + ): (DecryptedItemInterface | undefined)[] { + const results = this.findAllIncludingBlanks(uuids) + const mapped = results.map((i) => { + if (i == undefined || isDecryptedItem(i)) { + return i + } + + return undefined + }) + + return mapped as (DecryptedItemInterface | undefined)[] + } + + public allDecrypted(contentType: ContentType | ContentType[]): T[] { + return this.all(contentType).filter(isDecryptedItem) as T[] + } +} diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts b/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts new file mode 100644 index 000000000..643b96135 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts @@ -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({ + 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) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts b/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts new file mode 100644 index 000000000..af2007286 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts @@ -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>> = {} + private allCountableNotes = new Set() + + 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 { + let set = this.tagToNotesMap[uuid] + if (!set) { + set = new Set() + this.tagToNotesMap[uuid] = set + } + return set + } +} diff --git a/packages/models/src/Domain/Runtime/Collection/Payload/ImmutablePayloadCollection.ts b/packages/models/src/Domain/Runtime/Collection/Payload/ImmutablePayloadCollection.ts new file mode 100644 index 000000000..3d52bfe8d --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Payload/ImmutablePayloadCollection.ts @@ -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

{ + 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(payloads: T[] = []): ImmutablePayloadCollection { + const collection = new ImmutablePayloadCollection() + if (payloads.length > 0) { + collection.set(payloads) + } + + Object.freeze(collection) + return collection + } + + static FromCollection( + collection: PayloadCollection, + ): ImmutablePayloadCollection { + 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( + true, + mapCopy, + typedMapCopy as Partial>, + referenceMapCopy, + conflictMapCopy, + ) + + Object.freeze(result) + + return result + } + + mutableCopy(): PayloadCollection

{ + 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 + } +} diff --git a/packages/models/src/Domain/Runtime/Collection/Payload/PayloadCollection.ts b/packages/models/src/Domain/Runtime/Collection/Payload/PayloadCollection.ts new file mode 100644 index 000000000..1736b9e36 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Collection/Payload/PayloadCollection.ts @@ -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

+ extends Collection + implements CollectionInterface +{ + public integrityPayloads(): IntegrityPayload[] { + const nondeletedElements = this.nondeletedElements() + + return nondeletedElements.map((item) => ({ + uuid: item.uuid, + updated_at_timestamp: item.serverUpdatedAtTimestamp as number, + })) + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaEmit.ts b/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaEmit.ts new file mode 100644 index 000000000..318759e26 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaEmit.ts @@ -0,0 +1,30 @@ +import { extendArray } from '@standardnotes/utils' +import { EncryptedPayloadInterface, FullyFormedPayloadInterface, PayloadEmitSource } from '../../../Abstract/Payload' +import { SyncResolvedPayload } from '../Utilities/SyncResolvedPayload' + +export type DeltaEmit

= { + 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) + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaInterface.ts b/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaInterface.ts new file mode 100644 index 000000000..7f29b5510 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Abstract/DeltaInterface.ts @@ -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 +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Abstract/SyncDeltaInterface.ts b/packages/models/src/Domain/Runtime/Deltas/Abstract/SyncDeltaInterface.ts new file mode 100644 index 000000000..b73b23f08 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Abstract/SyncDeltaInterface.ts @@ -0,0 +1,8 @@ +import { ImmutablePayloadCollection } from '../../Collection/Payload/ImmutablePayloadCollection' +import { SyncDeltaEmit } from './DeltaEmit' + +export interface SyncDeltaInterface { + baseCollection: ImmutablePayloadCollection + + result(): SyncDeltaEmit +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Conflict.spec.ts b/packages/models/src/Domain/Runtime/Deltas/Conflict.spec.ts new file mode 100644 index 000000000..63c97883f --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Conflict.spec.ts @@ -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({ + uuid: uuid, + content_type: ContentType.ItemsKey, + content: FillItemContent({ + 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) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Deltas/Conflict.ts b/packages/models/src/Domain/Runtime/Deltas/Conflict.ts new file mode 100644 index 000000000..07f8fb101 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Conflict.ts @@ -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] + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/FileImport.ts b/packages/models/src/Domain/Runtime/Deltas/FileImport.ts new file mode 100644 index 000000000..042ea0cb7 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/FileImport.ts @@ -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() + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.spec.ts b/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.spec.ts new file mode 100644 index 000000000..3ec99b697 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.spec.ts @@ -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({ + uuid: '123', + content_type: ContentType.ItemsKey, + content: FillItemContent({ + 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 + + 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) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.ts b/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.ts new file mode 100644 index 000000000..4a8328aeb --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/ItemsKeyDelta.ts @@ -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, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/OfflineSaved.ts b/packages/models/src/Domain/Runtime/Deltas/OfflineSaved.ts new file mode 100644 index 000000000..c2639107b --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/OfflineSaved.ts @@ -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, + 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, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/OutOfSync.ts b/packages/models/src/Domain/Runtime/Deltas/OutOfSync.ts new file mode 100644 index 000000000..91540a549 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/OutOfSync.ts @@ -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 + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts new file mode 100644 index 000000000..e78d27b6d --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts @@ -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 + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts new file mode 100644 index 000000000..d695d90de --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts @@ -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({ + uuid: '123', + content_type: ContentType.Note, + dirty: true, + content: FillItemContent({ + title: 'foo', + }), + ...PayloadTimestampDefaults(), + updated_at_timestamp: 1, + }) + + baseCollection.set(basePayload) + + const rejectedPayload = basePayload.copy({ + content: FillItemContent({ + 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 + + expect(payload.content.title).toBe('foo') + expect(payload.updated_at_timestamp).toBe(1) + expect(payload.dirty).toBeFalsy() + }) +}) diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts new file mode 100644 index 000000000..f52f8dc86 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts @@ -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, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.spec.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.spec.ts new file mode 100644 index 000000000..991fc2610 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.spec.ts @@ -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({ + uuid: '123', + content_type: ContentType.ItemsKey, + content: FillItemContent({ + 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 + + 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) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts new file mode 100644 index 000000000..571098a9c --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts @@ -0,0 +1,87 @@ +import { ImmutablePayloadCollection } from './../Collection/Payload/ImmutablePayloadCollection' +import { ConflictDelta } from './Conflict' +import { isErrorDecryptingPayload, isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload' +import { ContentType, Uuid } from '@standardnotes/common' +import { HistoryMap } from '../History' +import { ServerSyncPushContextualPayload } from '../../Abstract/Contextual/ServerSyncPush' +import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState' +import { ItemsKeyDelta } from './ItemsKeyDelta' +import { extendSyncDelta, SyncDeltaEmit } from './Abstract/DeltaEmit' +import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' + +export class DeltaRemoteRetrieved implements SyncDeltaInterface { + constructor( + readonly baseCollection: ImmutablePayloadCollection, + readonly applyCollection: ImmutablePayloadCollection, + private itemsSavedOrSaving: ServerSyncPushContextualPayload[], + readonly historyMap: HistoryMap, + ) {} + + private isUuidOfPayloadCurrentlySavingOrSaved(uuid: Uuid): boolean { + return this.itemsSavedOrSaving.find((i) => i.uuid === uuid) != undefined + } + + public result(): SyncDeltaEmit { + const result: SyncDeltaEmit = { + emits: [], + ignored: [], + source: PayloadEmitSource.RemoteRetrieved, + } + + const conflicted: FullyFormedPayloadInterface[] = [] + + /** + * If we have retrieved an item that was saved as part of this ongoing sync operation, + * or if the item is locally dirty, filter it out of retrieved_items, and add to potential conflicts. + */ + for (const apply of this.applyCollection.all()) { + if (apply.content_type === ContentType.ItemsKey) { + const itemsKeyDeltaEmit = new ItemsKeyDelta(this.baseCollection, [apply]).result() + + extendSyncDelta(result, itemsKeyDeltaEmit) + + continue + } + + const isSavedOrSaving = this.isUuidOfPayloadCurrentlySavingOrSaved(apply.uuid) + + if (isSavedOrSaving) { + conflicted.push(apply) + + continue + } + + const base = this.baseCollection.find(apply.uuid) + if (base?.dirty && !isErrorDecryptingPayload(base)) { + conflicted.push(apply) + + continue + } + + result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection)) + } + + /** + * For any potential conflict above, we compare the values with current + * local values, and if they differ, we create a new payload that is a copy + * of the server payload. + */ + for (const conflict of conflicted) { + if (!isDecryptedPayload(conflict)) { + continue + } + + const base = this.baseCollection.find(conflict.uuid) + if (!base) { + continue + } + + const delta = new ConflictDelta(this.baseCollection, base, conflict, this.historyMap) + + extendSyncDelta(result, delta.result()) + } + + return result + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts new file mode 100644 index 000000000..f3d74a945 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts @@ -0,0 +1,99 @@ +import { ServerSyncSavedContextualPayload } from './../../Abstract/Contextual/ServerSyncSaved' +import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload' +import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' +import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource' +import { isDeletedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { PayloadEmitSource } from '../../Abstract/Payload' +import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState' +import { SyncDeltaEmit } from './Abstract/DeltaEmit' +import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' +import { BuildSyncResolvedParams, SyncResolvedPayload } from './Utilities/SyncResolvedPayload' +import { getIncrementedDirtyIndex } from '../DirtyCounter/DirtyCounter' + +export class DeltaRemoteSaved implements SyncDeltaInterface { + constructor( + readonly baseCollection: ImmutablePayloadCollection, + private readonly applyContextualPayloads: ServerSyncSavedContextualPayload[], + ) {} + + public result(): SyncDeltaEmit { + const processed: SyncResolvedPayload[] = [] + + for (const apply of this.applyContextualPayloads) { + const base = this.baseCollection.find(apply.uuid) + + if (!base) { + const discarded = new DeletedPayload( + { + ...apply, + deleted: true, + content: undefined, + ...BuildSyncResolvedParams({ + dirty: false, + lastSyncEnd: new Date(), + }), + }, + PayloadSource.RemoteSaved, + ) + + processed.push(discarded as SyncResolvedPayload) + continue + } + + /** + * If we save an item, but while in transit it is deleted locally, we want to keep + * local deletion status, and not old (false) deleted value that was sent to server. + */ + if (isDeletedPayload(base)) { + const baseWasDeletedAfterThisRequest = !apply.deleted + const regularDeletedPayload = apply.deleted + if (baseWasDeletedAfterThisRequest) { + const result = new DeletedPayload( + { + ...apply, + deleted: true, + content: undefined, + dirtyIndex: getIncrementedDirtyIndex(), + ...BuildSyncResolvedParams({ + dirty: true, + lastSyncEnd: new Date(), + }), + }, + PayloadSource.RemoteSaved, + ) + processed.push(result as SyncResolvedPayload) + } else if (regularDeletedPayload) { + const discarded = base.copy( + { + ...apply, + deleted: true, + ...BuildSyncResolvedParams({ + dirty: false, + lastSyncEnd: new Date(), + }), + }, + PayloadSource.RemoteSaved, + ) + processed.push(discarded as SyncResolvedPayload) + } + } else { + const result = payloadByFinalizingSyncState( + base.copy( + { + ...apply, + deleted: false, + }, + PayloadSource.RemoteSaved, + ), + this.baseCollection, + ) + processed.push(result) + } + } + + return { + emits: processed, + source: PayloadEmitSource.RemoteSaved, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts new file mode 100644 index 000000000..a7d484b2e --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts @@ -0,0 +1,56 @@ +import { extendArray, filterFromArray, Uuids } from '@standardnotes/utils' +import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' +import { PayloadsByAlternatingUuid } from '../../Utilities/Payload/PayloadsByAlternatingUuid' +import { isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { PayloadEmitSource } from '../../Abstract/Payload' +import { SyncDeltaEmit } from './Abstract/DeltaEmit' +import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' +import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload' + +/** + * UUID conflicts can occur if a user attmpts to import an old data + * backup with uuids from the old account into a new account. + * In uuid_conflict, we receive the value we attmpted to save. + */ +export class DeltaRemoteUuidConflicts implements SyncDeltaInterface { + constructor( + readonly baseCollection: ImmutablePayloadCollection, + readonly applyCollection: ImmutablePayloadCollection, + ) {} + + public result(): SyncDeltaEmit { + const results: SyncResolvedPayload[] = [] + const baseCollectionCopy = this.baseCollection.mutableCopy() + + for (const apply of this.applyCollection.all()) { + /** + * The payload in question may have been modified as part of alternating a uuid for + * another item. For example, alternating a uuid for a note will also affect the + * referencing tag, which would be added to `results`, but could also be inside + * of this.applyCollection. In this case we'd prefer the most recently modified value. + */ + const moreRecent = results.find((r) => r.uuid === apply.uuid) + const useApply = moreRecent || apply + + if (!isDecryptedPayload(useApply)) { + continue + } + + const alternateResults = PayloadsByAlternatingUuid( + useApply, + ImmutablePayloadCollection.FromCollection(baseCollectionCopy), + ) + + baseCollectionCopy.set(alternateResults) + + filterFromArray(results, (r) => Uuids(alternateResults).includes(r.uuid)) + + extendArray(results, alternateResults) + } + + return { + emits: results, + source: PayloadEmitSource.RemoteRetrieved, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Utilities/ApplyDirtyState.ts b/packages/models/src/Domain/Runtime/Deltas/Utilities/ApplyDirtyState.ts new file mode 100644 index 000000000..da85cb6dd --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Utilities/ApplyDirtyState.ts @@ -0,0 +1,36 @@ +import { ImmutablePayloadCollection } from '../../Collection/Payload/ImmutablePayloadCollection' +import { FullyFormedPayloadInterface } from '../../../Abstract/Payload/Interfaces/UnionTypes' +import { SyncResolvedPayload } from './SyncResolvedPayload' +import { getIncrementedDirtyIndex } from '../../DirtyCounter/DirtyCounter' + +export function payloadByFinalizingSyncState( + payload: FullyFormedPayloadInterface, + baseCollection: ImmutablePayloadCollection, +): SyncResolvedPayload { + const basePayload = baseCollection.find(payload.uuid) + + if (!basePayload) { + return payload.copyAsSyncResolved({ + dirty: false, + lastSyncEnd: new Date(), + }) + } + + const stillDirty = + basePayload.dirtyIndex && basePayload.globalDirtyIndexAtLastSync + ? basePayload.dirtyIndex > basePayload.globalDirtyIndexAtLastSync + : false + + return payload.copyAsSyncResolved({ + dirty: stillDirty, + dirtyIndex: stillDirty ? getIncrementedDirtyIndex() : undefined, + lastSyncEnd: new Date(), + }) +} + +export function payloadsByFinalizingSyncState( + payloads: FullyFormedPayloadInterface[], + baseCollection: ImmutablePayloadCollection, +): SyncResolvedPayload[] { + return payloads.map((p) => payloadByFinalizingSyncState(p, baseCollection)) +} diff --git a/packages/models/src/Domain/Runtime/Deltas/Utilities/SyncResolvedPayload.ts b/packages/models/src/Domain/Runtime/Deltas/Utilities/SyncResolvedPayload.ts new file mode 100644 index 000000000..3cf048108 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/Utilities/SyncResolvedPayload.ts @@ -0,0 +1,12 @@ +import { FullyFormedPayloadInterface } from '../../../Abstract/Payload' + +export interface SyncResolvedParams { + dirty: boolean + lastSyncEnd: Date +} + +export function BuildSyncResolvedParams(params: SyncResolvedParams): SyncResolvedParams { + return params +} + +export type SyncResolvedPayload = SyncResolvedParams & FullyFormedPayloadInterface diff --git a/packages/models/src/Domain/Runtime/Deltas/index.ts b/packages/models/src/Domain/Runtime/Deltas/index.ts new file mode 100644 index 000000000..3ff1e240a --- /dev/null +++ b/packages/models/src/Domain/Runtime/Deltas/index.ts @@ -0,0 +1,10 @@ +export * from './Conflict' +export * from './FileImport' +export * from './OutOfSync' +export * from './RemoteDataConflicts' +export * from './RemoteRetrieved' +export * from './RemoteSaved' +export * from './OfflineSaved' +export * from './RemoteUuidConflicts' +export * from './RemoteRejected' +export * from './Abstract/DeltaEmit' diff --git a/packages/models/src/Domain/Runtime/DirtyCounter/DirtyCounter.ts b/packages/models/src/Domain/Runtime/DirtyCounter/DirtyCounter.ts new file mode 100644 index 000000000..b9deb7d2e --- /dev/null +++ b/packages/models/src/Domain/Runtime/DirtyCounter/DirtyCounter.ts @@ -0,0 +1,10 @@ +let dirtyIndex = 0 + +export function getIncrementedDirtyIndex() { + dirtyIndex++ + return dirtyIndex +} + +export function getCurrentDirtyIndex() { + return dirtyIndex +} diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts new file mode 100644 index 000000000..0ed818617 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts @@ -0,0 +1,53 @@ +import { createNoteWithContent } from '../../Utilities/Test/SpecUtils' +import { ItemCollection } from '../Collection/Item/ItemCollection' +import { SNNote } from '../../Syncable/Note/Note' +import { itemsMatchingOptions } from './Search/SearchUtilities' +import { FilterDisplayOptions } from './DisplayOptions' + +describe('item display options', () => { + const collectionWithNotes = function (titles: (string | undefined)[] = [], bodies: string[] = []) { + const collection = new ItemCollection() + const notes: SNNote[] = [] + titles.forEach((title, index) => { + notes.push( + createNoteWithContent({ + title: title, + text: bodies[index], + }), + ) + }) + collection.set(notes) + return collection + } + + it('string query title', () => { + const query = 'foo' + + const options: FilterDisplayOptions = { + searchQuery: { query: query, includeProtectedNoteText: true }, + } + const collection = collectionWithNotes(['hello', 'fobar', 'foobar', 'foo']) + expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('string query text', async function () { + const query = 'foo' + const options: FilterDisplayOptions = { + searchQuery: { query: query, includeProtectedNoteText: true }, + } + const collection = collectionWithNotes( + [undefined, undefined, undefined, undefined], + ['hello', 'fobar', 'foobar', 'foo'], + ) + expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('string query title and text', async function () { + const query = 'foo' + const options: FilterDisplayOptions = { + searchQuery: { query: query, includeProtectedNoteText: true }, + } + const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar']) + expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts new file mode 100644 index 000000000..64a88771f --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts @@ -0,0 +1,25 @@ +import { ContentType } from '@standardnotes/common' +import { SmartView } from '../../Syncable/SmartView' +import { SNTag } from '../../Syncable/Tag' +import { CollectionSortDirection, CollectionSortProperty } from '../Collection/CollectionSort' +import { SearchQuery } from './Search/Types' +import { DisplayControllerCustomFilter } from './Types' + +export type DisplayOptions = FilterDisplayOptions & DisplayControllerOptions + +export interface FilterDisplayOptions { + tags?: SNTag[] + views?: SmartView[] + searchQuery?: SearchQuery + includePinned?: boolean + includeProtected?: boolean + includeTrashed?: boolean + includeArchived?: boolean +} + +export interface DisplayControllerOptions { + sortBy: CollectionSortProperty + sortDirection: CollectionSortDirection + hiddenContentTypes?: ContentType[] + customFilter?: DisplayControllerCustomFilter +} diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts new file mode 100644 index 000000000..5143b71b1 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts @@ -0,0 +1,78 @@ +import { ContentType } from '@standardnotes/common' +import { DecryptedItem } from '../../Abstract/Item' +import { SNTag } from '../../Syncable/Tag' +import { CompoundPredicate } from '../Predicate/CompoundPredicate' +import { ItemWithTags } from './Search/ItemWithTags' +import { itemMatchesQuery, itemPassesFilters } from './Search/SearchUtilities' +import { ItemFilter, ReferenceLookupCollection, SearchableDecryptedItem } from './Search/Types' +import { FilterDisplayOptions } from './DisplayOptions' + +export function computeUnifiedFilterForDisplayOptions( + options: FilterDisplayOptions, + collection: ReferenceLookupCollection, +): ItemFilter { + const filters = computeFiltersForDisplayOptions(options, collection) + + return (item: SearchableDecryptedItem) => { + return itemPassesFilters(item, filters) + } +} + +export function computeFiltersForDisplayOptions( + options: FilterDisplayOptions, + collection: ReferenceLookupCollection, +): ItemFilter[] { + const filters: ItemFilter[] = [] + + let viewsPredicate: CompoundPredicate | undefined = undefined + + if (options.views && options.views.length > 0) { + const compoundPredicate = new CompoundPredicate( + 'and', + options.views.map((t) => t.predicate), + ) + viewsPredicate = compoundPredicate + + filters.push((item) => { + if (compoundPredicate.keypathIncludesString('tags')) { + const noteWithTags = ItemWithTags.Create( + item.payload, + item, + collection.elementsReferencingElement(item, ContentType.Tag) as SNTag[], + ) + return compoundPredicate.matchesItem(noteWithTags) + } else { + return compoundPredicate.matchesItem(item) + } + }) + } + + if (options.tags && options.tags.length > 0) { + for (const tag of options.tags) { + filters.push((item) => tag.isReferencingItem(item)) + } + } + + if (options.includePinned === false && !viewsPredicate?.keypathIncludesString('pinned')) { + filters.push((item) => !item.pinned) + } + + if (options.includeProtected === false && !viewsPredicate?.keypathIncludesString('protected')) { + filters.push((item) => !item.protected) + } + + if (options.includeTrashed === false && !viewsPredicate?.keypathIncludesString('trashed')) { + filters.push((item) => !item.trashed) + } + + if (options.includeArchived === false && !viewsPredicate?.keypathIncludesString('archived')) { + filters.push((item) => !item.archived) + } + + if (options.searchQuery) { + const query = options.searchQuery + filters.push((item) => itemMatchesQuery(item, query, collection)) + } + + return filters +} diff --git a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts new file mode 100644 index 000000000..f46fbd32b --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts @@ -0,0 +1,256 @@ +import { CreateItemDelta } from './../Index/ItemDelta' +import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload' +import { createFile, createNote, createTag, mockUuid, pinnedContent } from './../../Utilities/Test/SpecUtils' +import { ContentType } from '@standardnotes/common' +import { DeletedItem, EncryptedItem } from '../../Abstract/Item' +import { EncryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload' +import { createNoteWithContent } from '../../Utilities/Test/SpecUtils' +import { ItemCollection } from './../Collection/Item/ItemCollection' +import { ItemDisplayController } from './ItemDisplayController' +import { SNNote } from '../../Syncable/Note' + +describe('item display controller', () => { + it('should sort items', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + const noteB = createNoteWithContent({ title: 'b' }) + collection.set([noteA, noteB]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + expect(controller.items()[0]).toEqual(noteA) + expect(controller.items()[1]).toEqual(noteB) + + controller.setDisplayOptions({ sortBy: 'title', sortDirection: 'dsc' }) + + expect(controller.items()[0]).toEqual(noteB) + expect(controller.items()[1]).toEqual(noteA) + }) + + it('should filter items', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + const noteB = createNoteWithContent({ title: 'b' }) + collection.set([noteA, noteB]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + controller.setDisplayOptions({ + customFilter: (note) => { + return note.title !== 'a' + }, + }) + + expect(controller.items()).toHaveLength(1) + expect(controller.items()[0].title).toEqual('b') + }) + + it('should resort items after collection change', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + collection.set([noteA]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + expect(controller.items()).toHaveLength(1) + + const noteB = createNoteWithContent({ title: 'b' }) + + const delta = CreateItemDelta({ changed: [noteB] }) + collection.onChange(delta) + controller.onCollectionChange(delta) + + expect(controller.items()).toHaveLength(2) + }) + + it('should not display encrypted items', () => { + const collection = new ItemCollection() + const noteA = new EncryptedItem( + new EncryptedPayload({ + uuid: mockUuid(), + content_type: ContentType.Note, + content: '004:...', + enc_item_key: '004:...', + items_key_id: mockUuid(), + errorDecrypting: true, + waitingForKey: false, + ...PayloadTimestampDefaults(), + }), + ) + collection.set([noteA]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + expect(controller.items()).toHaveLength(0) + }) + + it('pinned items should come first', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + const noteB = createNoteWithContent({ title: 'b' }) + collection.set([noteA, noteB]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + expect(controller.items()[0]).toEqual(noteA) + expect(controller.items()[1]).toEqual(noteB) + + expect(collection.all()).toHaveLength(2) + + const pinnedNoteB = new SNNote( + noteB.payload.copy({ + content: { + ...noteB.content, + ...pinnedContent(), + }, + }), + ) + expect(pinnedNoteB.pinned).toBeTruthy() + + const delta = CreateItemDelta({ changed: [pinnedNoteB] }) + collection.onChange(delta) + controller.onCollectionChange(delta) + + expect(controller.items()[0]).toEqual(pinnedNoteB) + expect(controller.items()[1]).toEqual(noteA) + }) + + it('should not display deleted items', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + collection.set([noteA]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + const deletedItem = new DeletedItem( + new DeletedPayload({ + ...noteA.payload, + content: undefined, + deleted: true, + }), + ) + + const delta = CreateItemDelta({ changed: [deletedItem] }) + collection.onChange(delta) + controller.onCollectionChange(delta) + + expect(controller.items()).toHaveLength(0) + }) + + it('discarding elements should remove from display', () => { + const collection = new ItemCollection() + const noteA = createNoteWithContent({ title: 'a' }) + collection.set([noteA]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + + const delta = CreateItemDelta({ discarded: [noteA] as unknown as DeletedItem[] }) + collection.onChange(delta) + controller.onCollectionChange(delta) + + expect(controller.items()).toHaveLength(0) + }) + + it('should ignore items not matching content type on construction', () => { + const collection = new ItemCollection() + const note = createNoteWithContent({ title: 'a' }) + const tag = createTag() + collection.set([note, tag]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + expect(controller.items()).toHaveLength(1) + }) + + it('should ignore items not matching content type on sort change', () => { + const collection = new ItemCollection() + const note = createNoteWithContent({ title: 'a' }) + const tag = createTag() + collection.set([note, tag]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + controller.setDisplayOptions({ sortBy: 'created_at', sortDirection: 'asc' }) + expect(controller.items()).toHaveLength(1) + }) + + it('should ignore collection deltas with items not matching content types', () => { + const collection = new ItemCollection() + const note = createNoteWithContent({ title: 'a' }) + collection.set([note]) + + const controller = new ItemDisplayController(collection, [ContentType.Note], { + sortBy: 'title', + sortDirection: 'asc', + }) + const tag = createTag() + + const delta = CreateItemDelta({ inserted: [tag], changed: [note] }) + collection.onChange(delta) + controller.onCollectionChange(delta) + + expect(controller.items()).toHaveLength(1) + }) + + it('should display compound item types', () => { + const collection = new ItemCollection() + const note = createNoteWithContent({ title: 'Z' }) + const file = createFile('A') + collection.set([note, file]) + + const controller = new ItemDisplayController(collection, [ContentType.Note, ContentType.File], { + sortBy: 'title', + sortDirection: 'asc', + }) + + expect(controller.items()[0]).toEqual(file) + expect(controller.items()[1]).toEqual(note) + + controller.setDisplayOptions({ sortBy: 'title', sortDirection: 'dsc' }) + + expect(controller.items()[0]).toEqual(note) + expect(controller.items()[1]).toEqual(file) + }) + + it('should hide hidden types', () => { + const collection = new ItemCollection() + const note = createNote() + const file = createFile() + collection.set([note, file]) + + const controller = new ItemDisplayController(collection, [ContentType.Note, ContentType.File], { + sortBy: 'title', + sortDirection: 'asc', + }) + + expect(controller.items()).toHaveLength(2) + + controller.setDisplayOptions({ hiddenContentTypes: [ContentType.File] }) + + expect(controller.items()).toHaveLength(1) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts new file mode 100644 index 000000000..60c35fe13 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts @@ -0,0 +1,138 @@ +import { ContentType } from '@standardnotes/common' +import { compareValues } from '@standardnotes/utils' +import { isDeletedItem, isEncryptedItem } from '../../Abstract/Item' +import { ItemDelta } from '../Index/ItemDelta' +import { DisplayControllerOptions } from './DisplayOptions' +import { sortTwoItems } from './SortTwoItems' +import { UuidToSortedPositionMap, DisplayItem, ReadonlyItemCollection } from './Types' + +export class ItemDisplayController { + private sortMap: UuidToSortedPositionMap = {} + private sortedItems: I[] = [] + private needsSort = true + + constructor( + private readonly collection: ReadonlyItemCollection, + public readonly contentTypes: ContentType[], + private options: DisplayControllerOptions, + ) { + this.filterThenSortElements(this.collection.all(this.contentTypes) as I[]) + } + + public items(): I[] { + return this.sortedItems + } + + setDisplayOptions(displayOptions: Partial): void { + this.options = { ...this.options, ...displayOptions } + this.needsSort = true + + this.filterThenSortElements(this.collection.all(this.contentTypes) as I[]) + } + + onCollectionChange(delta: ItemDelta): void { + const items = [...delta.changed, ...delta.inserted, ...delta.discarded].filter((i) => + this.contentTypes.includes(i.content_type), + ) + this.filterThenSortElements(items as I[]) + } + + private filterThenSortElements(elements: I[]): void { + for (const element of elements) { + const previousIndex = this.sortMap[element.uuid] + const previousElement = previousIndex != undefined ? this.sortedItems[previousIndex] : undefined + + const remove = () => { + if (previousIndex != undefined) { + delete this.sortMap[element.uuid] + + /** We don't yet remove the element directly from the array, since mutating + * the array inside a loop could render all other upcoming indexes invalid */ + ;(this.sortedItems[previousIndex] as unknown) = undefined + + /** Since an element is being removed from the array, we need to recompute + * the new positions for elements that are staying */ + this.needsSort = true + } + } + + if (isDeletedItem(element) || isEncryptedItem(element)) { + remove() + continue + } + + const passes = !this.collection.has(element.uuid) + ? false + : this.options.hiddenContentTypes?.includes(element.content_type) + ? false + : this.options.customFilter + ? this.options.customFilter(element) + : true + + if (passes) { + if (previousElement != undefined) { + /** Check to see if the element has changed its sort value. If so, we need to re-sort. */ + const previousValue = previousElement[this.options.sortBy] + + const newValue = element[this.options.sortBy] + + /** Replace the current element with the new one. */ + this.sortedItems[previousIndex] = element + + /** If the pinned status of the element has changed, it needs to be resorted */ + const pinChanged = previousElement.pinned !== element.pinned + + if (!compareValues(previousValue, newValue) || pinChanged) { + /** Needs resort because its re-sort value has changed, + * and thus its position might change */ + this.needsSort = true + } + } else { + /** Has not yet been inserted */ + this.sortedItems.push(element) + + /** Needs re-sort because we're just pushing the element to the end here */ + this.needsSort = true + } + } else { + /** Doesn't pass filter, remove from sorted and filtered */ + remove() + } + } + + if (this.needsSort) { + this.needsSort = false + this.resortItems() + } + } + + /** Resort the sortedItems array, and update the saved positions */ + private resortItems() { + const resorted = this.sortedItems.sort((a, b) => { + return sortTwoItems(a, b, this.options.sortBy, this.options.sortDirection) + }) + + /** + * Now that resorted contains the sorted elements (but also can contain undefined element) + * we create another array that filters out any of the undefinedes. We also keep track of the + * current index while we loop and set that in the this.sortMap. + * */ + const results = [] + let currentIndex = 0 + + /** @O(n) */ + for (const element of resorted) { + if (!element) { + continue + } + + results.push(element) + + this.sortMap[element.uuid] = currentIndex + + currentIndex++ + } + + this.sortedItems = results + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Search/ItemWithTags.ts b/packages/models/src/Domain/Runtime/Display/Search/ItemWithTags.ts new file mode 100644 index 000000000..e92c2dc11 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Search/ItemWithTags.ts @@ -0,0 +1,36 @@ +import { SearchableDecryptedItem } from './Types' +import { ItemContent } from '../../../Abstract/Content/ItemContent' +import { DecryptedItem } from '../../../Abstract/Item' +import { DecryptedPayloadInterface } from '../../../Abstract/Payload/Interfaces/DecryptedPayload' +import { SNTag } from '../../../Syncable/Tag' + +interface ItemWithTagsContent extends ItemContent { + tags: SNTag[] +} + +export class ItemWithTags extends DecryptedItem implements SearchableDecryptedItem { + constructor( + payload: DecryptedPayloadInterface, + private item: SearchableDecryptedItem, + public readonly tags?: SNTag[], + ) { + super(payload) + this.tags = tags || payload.content.tags + } + + static Create(payload: DecryptedPayloadInterface, item: SearchableDecryptedItem, tags?: SNTag[]) { + return new ItemWithTags(payload as DecryptedPayloadInterface, item, tags) + } + + get tagsCount(): number { + return this.tags?.length || 0 + } + + get title(): string | undefined { + return this.item.title + } + + get text(): string | undefined { + return this.item.text + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts new file mode 100644 index 000000000..b8b2db815 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts @@ -0,0 +1,97 @@ +import { ContentType } from '@standardnotes/common' +import { SNTag } from '../../../Syncable/Tag' +import { FilterDisplayOptions } from '../DisplayOptions' +import { computeFiltersForDisplayOptions } from '../DisplayOptionsToFilters' +import { SearchableItem } from './SearchableItem' +import { ReferenceLookupCollection, ItemFilter, SearchQuery, SearchableDecryptedItem } from './Types' + +enum MatchResult { + None = 0, + Title = 1, + Text = 2, + TitleAndText = Title + Text, + Uuid = 5, +} + +export function itemsMatchingOptions( + options: FilterDisplayOptions, + fromItems: SearchableDecryptedItem[], + collection: ReferenceLookupCollection, +): SearchableItem[] { + const filters = computeFiltersForDisplayOptions(options, collection) + + return fromItems.filter((item) => { + return itemPassesFilters(item, filters) + }) +} +export function itemPassesFilters(item: SearchableDecryptedItem, filters: ItemFilter[]) { + for (const filter of filters) { + if (!filter(item)) { + return false + } + } + return true +} + +export function itemMatchesQuery( + itemToMatch: SearchableDecryptedItem, + searchQuery: SearchQuery, + collection: ReferenceLookupCollection, +): boolean { + const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.Tag) as SNTag[] + const someTagsMatches = itemTags.some((tag) => matchResultForStringQuery(tag, searchQuery.query) !== MatchResult.None) + + if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) { + const match = matchResultForStringQuery(itemToMatch, searchQuery.query) + return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches + } + + return matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches +} + +function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult { + if (searchString.length === 0) { + return MatchResult.TitleAndText + } + + const title = item.title?.toLowerCase() + const text = item.text?.toLowerCase() + const lowercaseText = searchString.toLowerCase() + const words = lowercaseText.split(' ') + const quotedText = stringBetweenQuotes(lowercaseText) + + if (quotedText) { + return ( + (title?.includes(quotedText) ? MatchResult.Title : MatchResult.None) + + (text?.includes(quotedText) ? MatchResult.Text : MatchResult.None) + ) + } + + if (stringIsUuid(lowercaseText)) { + return item.uuid === lowercaseText ? MatchResult.Uuid : MatchResult.None + } + + const matchesTitle = + title && + words.every((word) => { + return title.indexOf(word) >= 0 + }) + + const matchesBody = + text && + words.every((word) => { + return text.indexOf(word) >= 0 + }) + + return (matchesTitle ? MatchResult.Title : 0) + (matchesBody ? MatchResult.Text : 0) +} + +function stringBetweenQuotes(text: string) { + const matches = text.match(/"(.*?)"/) + return matches ? matches[1] : null +} + +function stringIsUuid(text: string) { + const matches = text.match(/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/) + return matches ? true : false +} diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchableItem.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchableItem.ts new file mode 100644 index 000000000..48773f647 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchableItem.ts @@ -0,0 +1,5 @@ +export interface SearchableItem { + uuid: string + title?: string + text?: string +} diff --git a/packages/models/src/Domain/Runtime/Display/Search/Types.ts b/packages/models/src/Domain/Runtime/Display/Search/Types.ts new file mode 100644 index 000000000..5e47f0111 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Search/Types.ts @@ -0,0 +1,16 @@ +import { ItemCollection } from './../../Collection/Item/ItemCollection' +import { DecryptedItemInterface } from '../../../Abstract/Item' +import { SearchableItem } from './SearchableItem' + +export type SearchQuery = { + query: string + includeProtectedNoteText: boolean +} + +export interface ReferenceLookupCollection { + elementsReferencingElement: ItemCollection['elementsReferencingElement'] +} + +export type SearchableDecryptedItem = SearchableItem & DecryptedItemInterface + +export type ItemFilter = (item: SearchableDecryptedItem) => boolean diff --git a/packages/models/src/Domain/Runtime/Display/SortTwoItems.spec.ts b/packages/models/src/Domain/Runtime/Display/SortTwoItems.spec.ts new file mode 100644 index 000000000..830c5518c --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/SortTwoItems.spec.ts @@ -0,0 +1,29 @@ +import { SortLeftFirst, SortRightFirst, sortTwoItems } from './SortTwoItems' +import { createNoteWithContent } from '../../Utilities/Test/SpecUtils' +import { SNNote } from '../../Syncable/Note' + +describe('sort two items', () => { + it('should sort correctly by dates', () => { + const noteA = createNoteWithContent({}, new Date(0)) + const noteB = createNoteWithContent({}, new Date(1)) + + expect(sortTwoItems(noteA, noteB, 'created_at', 'asc')).toEqual(SortLeftFirst) + expect(sortTwoItems(noteA, noteB, 'created_at', 'dsc')).toEqual(SortRightFirst) + }) + + it('should sort by title', () => { + const noteA = createNoteWithContent({ title: 'a' }) + const noteB = createNoteWithContent({ title: 'b' }) + + expect(sortTwoItems(noteA, noteB, 'title', 'asc')).toEqual(SortLeftFirst) + expect(sortTwoItems(noteA, noteB, 'title', 'dsc')).toEqual(SortRightFirst) + }) + + it('should sort correctly by title and pinned', () => { + const noteA = createNoteWithContent({ title: 'a' }) + const noteB = { ...createNoteWithContent({ title: 'b' }), pinned: true } as jest.Mocked + + expect(sortTwoItems(noteA, noteB, 'title', 'asc')).toEqual(SortRightFirst) + expect(sortTwoItems(noteA, noteB, 'title', 'dsc')).toEqual(SortRightFirst) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Display/SortTwoItems.ts b/packages/models/src/Domain/Runtime/Display/SortTwoItems.ts new file mode 100644 index 000000000..677170c83 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/SortTwoItems.ts @@ -0,0 +1,83 @@ +import { isString } from '@standardnotes/utils' +import { CollectionSort, CollectionSortDirection, CollectionSortProperty } from '../Collection/CollectionSort' +import { DisplayItem } from './Types' + +export const SortLeftFirst = -1 +export const SortRightFirst = 1 +export const KeepSameOrder = 0 + +/** @O(n * log(n)) */ +export function sortTwoItems( + a: DisplayItem | undefined, + b: DisplayItem | undefined, + sortBy: CollectionSortProperty, + sortDirection: CollectionSortDirection, + bypassPinCheck = false, +): number { + /** If the elements are undefined, move to beginning */ + if (!a) { + return SortLeftFirst + } + + if (!b) { + return SortRightFirst + } + + if (!bypassPinCheck) { + if (a.pinned && b.pinned) { + return sortTwoItems(a, b, sortBy, sortDirection, true) + } + if (a.pinned) { + return SortLeftFirst + } + if (b.pinned) { + return SortRightFirst + } + } + + const aValue = a[sortBy] || '' + const bValue = b[sortBy] || '' + const smallerNaturallyComesFirst = sortDirection === 'asc' + + let compareResult = KeepSameOrder + + /** + * Check for string length due to issue on React Native 0.65.1 + * where empty strings causes crash: + * https://github.com/facebook/react-native/issues/32174 + * */ + if ( + sortBy === CollectionSort.Title && + isString(aValue) && + isString(bValue) && + aValue.length > 0 && + bValue.length > 0 + ) { + compareResult = aValue.localeCompare(bValue, 'en', { numeric: true }) + } else if (aValue > bValue) { + compareResult = SortRightFirst + } else if (aValue < bValue) { + compareResult = SortLeftFirst + } else { + compareResult = KeepSameOrder + } + + const isLeftSmaller = compareResult === SortLeftFirst + const isLeftBigger = compareResult === SortRightFirst + + if (isLeftSmaller) { + if (smallerNaturallyComesFirst) { + return SortLeftFirst + } else { + return SortRightFirst + } + } else if (isLeftBigger) { + if (smallerNaturallyComesFirst) { + return SortRightFirst + } else { + return SortLeftFirst + } + } else { + return KeepSameOrder + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Types.ts b/packages/models/src/Domain/Runtime/Display/Types.ts new file mode 100644 index 000000000..feb607fc7 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Types.ts @@ -0,0 +1,13 @@ +import { Uuid } from '@standardnotes/common' +import { DecryptedItemInterface } from '../../Abstract/Item' +import { SortableItem } from '../Collection/CollectionSort' +import { ItemCollection } from '../Collection/Item/ItemCollection' + +export type DisplayControllerCustomFilter = (element: DisplayItem) => boolean +export type UuidToSortedPositionMap = Record +export type DisplayItem = SortableItem & DecryptedItemInterface + +export interface ReadonlyItemCollection { + all: ItemCollection['all'] + has: ItemCollection['has'] +} diff --git a/packages/models/src/Domain/Runtime/Display/index.ts b/packages/models/src/Domain/Runtime/Display/index.ts new file mode 100644 index 000000000..6e66f2c27 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/index.ts @@ -0,0 +1,8 @@ +export * from './DisplayOptions' +export * from './DisplayOptionsToFilters' +export * from './ItemDisplayController' +export * from './Search/ItemWithTags' +export * from './Search/SearchableItem' +export * from './Search/SearchUtilities' +export * from './Search/Types' +export * from './Types' diff --git a/packages/models/src/Domain/Runtime/History/Generator.ts b/packages/models/src/Domain/Runtime/History/Generator.ts new file mode 100644 index 000000000..9f14ea3ee --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/Generator.ts @@ -0,0 +1,24 @@ +import { DecryptedPayloadInterface } from './../../Abstract/Payload/Interfaces/DecryptedPayload' +import { ContentType } from '@standardnotes/common' +import { NoteContent } from '../../Syncable/Note' +import { HistoryEntry } from './HistoryEntry' +import { NoteHistoryEntry } from './NoteHistoryEntry' + +export function CreateHistoryEntryForPayload( + payload: DecryptedPayloadInterface, + previousEntry?: HistoryEntry, +): HistoryEntry { + const type = payload.content_type + const historyItemClass = historyClassForContentType(type) + const entry = new historyItemClass(payload, previousEntry) + return entry +} + +function historyClassForContentType(contentType: ContentType) { + switch (contentType) { + case ContentType.Note: + return NoteHistoryEntry + default: + return HistoryEntry + } +} diff --git a/packages/models/src/Domain/Runtime/History/HistoryEntry.ts b/packages/models/src/Domain/Runtime/History/HistoryEntry.ts new file mode 100644 index 000000000..1444e49b5 --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/HistoryEntry.ts @@ -0,0 +1,98 @@ +import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem' +import { DecryptedPayloadInterface } from './../../Abstract/Payload/Interfaces/DecryptedPayload' +import { isNullOrUndefined } from '@standardnotes/utils' +import { CreateDecryptedItemFromPayload } from '../../Utilities/Item/ItemGenerator' +import { NoteContent } from '../../Syncable/Note' +import { HistoryEntryInterface } from './HistoryEntryInterface' + +export class HistoryEntry implements HistoryEntryInterface { + public readonly payload: DecryptedPayloadInterface + public readonly previousEntry?: HistoryEntry + protected readonly defaultContentKeyToDiffOn: keyof NoteContent = 'text' + protected readonly textCharDiffLength: number + protected readonly hasPreviousEntry: boolean + + constructor(payload: DecryptedPayloadInterface, previousEntry?: HistoryEntry) { + this.payload = payload.copy() + this.previousEntry = previousEntry + this.hasPreviousEntry = !isNullOrUndefined(previousEntry) + /** We'll try to compute the delta based on an assumed + * content property of `text`, if it exists. */ + const propertyValue = this.payload.content[this.defaultContentKeyToDiffOn] as string | undefined + + if (propertyValue) { + if (previousEntry) { + const previousValue = (previousEntry.payload.content[this.defaultContentKeyToDiffOn] as string)?.length || 0 + this.textCharDiffLength = propertyValue.length - previousValue + } else { + this.textCharDiffLength = propertyValue.length + } + } else { + this.textCharDiffLength = 0 + } + } + + public itemFromPayload(): DecryptedItemInterface { + return CreateDecryptedItemFromPayload(this.payload) + } + + public isSameAsEntry(entry: HistoryEntry): boolean { + if (!entry) { + return false + } + const lhs = this.itemFromPayload() + const rhs = entry.itemFromPayload() + const datesEqual = lhs.userModifiedDate.getTime() === rhs.userModifiedDate.getTime() + if (!datesEqual) { + return false + } + /** Dates are the same, but because JS is only accurate to milliseconds, + * items can have different content but same dates */ + return lhs.isItemContentEqualWith(rhs) + } + + public isDiscardable(): boolean { + return false + } + + public operationVector(): number { + /** + * We'll try to use the value of `textCharDiffLength` + * to help determine this, if it's set + */ + if (this.textCharDiffLength !== undefined) { + if (!this.hasPreviousEntry || this.textCharDiffLength === 0) { + return 0 + } else if (this.textCharDiffLength < 0) { + return -1 + } else { + return 1 + } + } + + /** Otherwise use a default value of 1 */ + return 1 + } + + public deltaSize(): number { + /** + * Up to the subclass to determine how large the delta was, + * i.e number of characters changed. + * But this general class won't be able to determine which property it + * should diff on, or even its format. + */ + /** + * We can return the `textCharDiffLength` if it's set, + * otherwise, just return 1; + */ + if (this.textCharDiffLength !== undefined) { + return Math.abs(this.textCharDiffLength) + } + /** + * Otherwise return 1 here to constitute a basic positive delta. + * The value returned should always be positive. Override `operationVector` + * to return the direction of the delta. + */ + return 1 + } +} diff --git a/packages/models/src/Domain/Runtime/History/HistoryEntryInterface.ts b/packages/models/src/Domain/Runtime/History/HistoryEntryInterface.ts new file mode 100644 index 000000000..73066ec7d --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/HistoryEntryInterface.ts @@ -0,0 +1,13 @@ +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { NoteContent } from '../../Syncable/Note/NoteContent' + +export interface HistoryEntryInterface { + readonly payload: DecryptedPayloadInterface + readonly previousEntry?: HistoryEntryInterface + itemFromPayload(): DecryptedItemInterface + isSameAsEntry(entry: HistoryEntryInterface): boolean + isDiscardable(): boolean + operationVector(): number + deltaSize(): number +} diff --git a/packages/models/src/Domain/Runtime/History/HistoryMap.ts b/packages/models/src/Domain/Runtime/History/HistoryMap.ts new file mode 100644 index 000000000..826415f80 --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/HistoryMap.ts @@ -0,0 +1,10 @@ +import { Uuid } from '@standardnotes/common' +import { HistoryEntry } from './HistoryEntry' + +export type HistoryMap = Record + +export const historyMapFunctions = { + getNewestRevision: (history: HistoryEntry[]): HistoryEntry | undefined => { + return history[0] + }, +} diff --git a/packages/models/src/Domain/Runtime/History/NoteHistoryEntry.ts b/packages/models/src/Domain/Runtime/History/NoteHistoryEntry.ts new file mode 100644 index 000000000..bffdbb60e --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/NoteHistoryEntry.ts @@ -0,0 +1,28 @@ +import { isEmpty } from '@standardnotes/utils' +import { HistoryEntry } from './HistoryEntry' + +export class NoteHistoryEntry extends HistoryEntry { + previewTitle(): string { + if (this.payload.updated_at.getTime() > 0) { + return this.payload.updated_at.toLocaleString() + } else { + return this.payload.created_at.toLocaleString() + } + } + + previewSubTitle(): string { + if (!this.hasPreviousEntry) { + return `${this.textCharDiffLength} characters loaded` + } else if (this.textCharDiffLength < 0) { + return `${this.textCharDiffLength * -1} characters removed` + } else if (this.textCharDiffLength > 0) { + return `${this.textCharDiffLength} characters added` + } else { + return 'Title or metadata changed' + } + } + + public override isDiscardable(): boolean { + return isEmpty(this.payload.content.text) + } +} diff --git a/packages/models/src/Domain/Runtime/History/index.ts b/packages/models/src/Domain/Runtime/History/index.ts new file mode 100644 index 000000000..aee5b736d --- /dev/null +++ b/packages/models/src/Domain/Runtime/History/index.ts @@ -0,0 +1,5 @@ +export * from './Generator' +export * from './HistoryEntry' +export * from './HistoryMap' +export * from './NoteHistoryEntry' +export * from './HistoryEntryInterface' diff --git a/packages/models/src/Domain/Runtime/Index/ItemDelta.ts b/packages/models/src/Domain/Runtime/Index/ItemDelta.ts new file mode 100644 index 000000000..7a205157e --- /dev/null +++ b/packages/models/src/Domain/Runtime/Index/ItemDelta.ts @@ -0,0 +1,24 @@ +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedItemInterface, DeletedItemInterface, EncryptedItemInterface } from '../../Abstract/Item' +import { AnyItemInterface } from '../../Abstract/Item/Interfaces/UnionTypes' + +export interface ItemDelta { + changed: AnyItemInterface[] + inserted: AnyItemInterface[] + /** Items that were deleted and finished sync */ + discarded: DeletedItemInterface[] + /** Items which have encrypted overwrite protection enabled */ + ignored: EncryptedItemInterface[] + /** Items which were previously error decrypting which have now been successfully decrypted */ + unerrored: DecryptedItemInterface[] +} + +export function CreateItemDelta(partial: Partial): ItemDelta { + return { + changed: partial.changed || [], + inserted: partial.inserted || [], + discarded: partial.discarded || [], + ignored: partial.ignored || [], + unerrored: partial.unerrored || [], + } +} diff --git a/packages/models/src/Domain/Runtime/Index/SNIndex.ts b/packages/models/src/Domain/Runtime/Index/SNIndex.ts new file mode 100644 index 000000000..94f2fc49c --- /dev/null +++ b/packages/models/src/Domain/Runtime/Index/SNIndex.ts @@ -0,0 +1,5 @@ +import { ItemDelta } from './ItemDelta' + +export interface SNIndex { + onChange(delta: ItemDelta): void +} diff --git a/packages/models/src/Domain/Runtime/Predicate/CompoundPredicate.ts b/packages/models/src/Domain/Runtime/Predicate/CompoundPredicate.ts new file mode 100644 index 000000000..1b27e2995 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/CompoundPredicate.ts @@ -0,0 +1,46 @@ +import { PredicateTarget, PredicateCompoundOperator, PredicateInterface, PredicateJsonForm } from './Interface' + +export class CompoundPredicate implements PredicateInterface { + constructor( + public readonly operator: PredicateCompoundOperator, + public readonly predicates: PredicateInterface[], + ) {} + + matchesItem(item: T): boolean { + if (this.operator === 'and') { + for (const subPredicate of this.predicates) { + if (!subPredicate.matchesItem(item)) { + return false + } + } + return true + } + + if (this.operator === 'or') { + for (const subPredicate of this.predicates) { + if (subPredicate.matchesItem(item)) { + return true + } + } + return false + } + + return false + } + + keypathIncludesString(verb: string): boolean { + for (const subPredicate of this.predicates) { + if (subPredicate.keypathIncludesString(verb)) { + return true + } + } + return false + } + + toJson(): PredicateJsonForm { + return { + operator: this.operator, + value: this.predicates.map((predicate) => predicate.toJson()), + } + } +} diff --git a/packages/models/src/Domain/Runtime/Predicate/Generators.ts b/packages/models/src/Domain/Runtime/Predicate/Generators.ts new file mode 100644 index 000000000..d8bb6d0f3 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Generators.ts @@ -0,0 +1,140 @@ +import { CompoundPredicate } from './CompoundPredicate' +import { IncludesPredicate } from './IncludesPredicate' +import { + AllPredicateCompoundOperators, + PredicateCompoundOperator, + PredicateInterface, + PredicateOperator, + SureValue, + PredicateJsonForm, + AllPredicateOperators, + RawPredicateInArrayForm, + SureValueNonObjectTypesAsStrings, + StringKey, + PredicateTarget, +} from './Interface' +import { NotPredicate } from './NotPredicate' +import { Predicate } from './Predicate' + +export function predicateFromArguments( + keypath: StringKey | undefined, + operator: PredicateOperator, + value: SureValue | PredicateJsonForm, +): PredicateInterface { + if (AllPredicateCompoundOperators.includes(operator as PredicateCompoundOperator)) { + return compoundPredicateFromArguments(operator, value as unknown as PredicateJsonForm[]) + } else if (operator === 'not') { + return new NotPredicate(predicateFromJson(value as PredicateJsonForm)) + } else if (operator === 'includes' && keypath) { + if (isSureValue(value)) { + return new Predicate(keypath, operator, value) + } else { + return new IncludesPredicate(keypath, predicateFromJson(value as PredicateJsonForm)) + } + } else if (keypath) { + return new Predicate(keypath, operator, value as SureValue) + } + + throw Error('Invalid predicate arguments') +} + +export function compoundPredicateFromArguments( + operator: PredicateOperator, + value: PredicateJsonForm[], +): PredicateInterface { + const subPredicates = value.map((jsonPredicate) => { + return predicateFromJson(jsonPredicate) + }) + return new CompoundPredicate(operator as PredicateCompoundOperator, subPredicates) +} + +export function notPredicateFromArguments(value: PredicateJsonForm): PredicateInterface { + const subPredicate = predicateFromJson(value) + return new NotPredicate(subPredicate) +} + +export function includesPredicateFromArguments( + keypath: StringKey, + value: PredicateJsonForm, +): PredicateInterface { + const subPredicate = predicateFromJson(value) + return new IncludesPredicate(keypath, subPredicate) +} + +export function predicateFromJson(values: PredicateJsonForm): PredicateInterface { + if (Array.isArray(values)) { + throw Error('Invalid predicateFromJson value') + } + return predicateFromArguments( + values.keypath as StringKey, + values.operator, + isValuePredicateInArrayForm(values.value) + ? predicateDSLArrayToJsonPredicate(values.value) + : (values.value as PredicateJsonForm), + ) +} + +export function predicateFromDSLString(dsl: string): PredicateInterface { + try { + const components = JSON.parse(dsl.substring(1, dsl.length)) as string[] + components.shift() + const predicateJson = predicateDSLArrayToJsonPredicate(components as RawPredicateInArrayForm) + return predicateFromJson(predicateJson) + } catch (e) { + throw Error(`Invalid smart view syntax ${e}`) + } +} + +function isValuePredicateInArrayForm( + value: SureValue | PredicateJsonForm | PredicateJsonForm[] | RawPredicateInArrayForm, +): value is RawPredicateInArrayForm { + return Array.isArray(value) && AllPredicateOperators.includes(value[1] as PredicateOperator) +} + +function isSureValue(value: unknown): value is SureValue { + if (SureValueNonObjectTypesAsStrings.includes(typeof value)) { + return true + } + + if (Array.isArray(value)) { + return !isValuePredicateInArrayForm(value) + } + + return false +} + +function predicateDSLArrayToJsonPredicate(predicateArray: RawPredicateInArrayForm): PredicateJsonForm { + const predicateValue = predicateArray[2] as + | SureValue + | SureValue[] + | RawPredicateInArrayForm + | RawPredicateInArrayForm[] + + let resolvedPredicateValue: PredicateJsonForm | SureValue | PredicateJsonForm[] + + if (Array.isArray(predicateValue)) { + const level1CondensedValue = predicateValue as SureValue[] | RawPredicateInArrayForm | RawPredicateInArrayForm[] + + if (Array.isArray(level1CondensedValue[0])) { + const level2CondensedValue = level1CondensedValue as RawPredicateInArrayForm[] + resolvedPredicateValue = level2CondensedValue.map((subPredicate) => + predicateDSLArrayToJsonPredicate(subPredicate), + ) + } else if (isValuePredicateInArrayForm(predicateValue[1])) { + const level2CondensedValue = level1CondensedValue as RawPredicateInArrayForm + resolvedPredicateValue = predicateDSLArrayToJsonPredicate(level2CondensedValue) + } else { + const level2CondensedValue = predicateValue as SureValue + resolvedPredicateValue = level2CondensedValue + } + } else { + const level1CondensedValue = predicateValue as SureValue + resolvedPredicateValue = level1CondensedValue + } + + return { + keypath: predicateArray[0], + operator: predicateArray[1] as PredicateOperator, + value: resolvedPredicateValue, + } +} diff --git a/packages/models/src/Domain/Runtime/Predicate/IncludesPredicate.ts b/packages/models/src/Domain/Runtime/Predicate/IncludesPredicate.ts new file mode 100644 index 000000000..afc3fc630 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/IncludesPredicate.ts @@ -0,0 +1,32 @@ +import { PredicateTarget, PredicateInterface, PredicateJsonForm, StringKey } from './Interface' + +export class IncludesPredicate implements PredicateInterface { + constructor(private readonly keypath: StringKey, public readonly predicate: PredicateInterface) {} + + matchesItem(item: T): boolean { + const keyPathComponents = this.keypath.split('.') as StringKey[] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const valueAtKeyPath: T = keyPathComponents.reduce((previous, current) => { + return previous && previous[current] + }, item) + + if (!Array.isArray(valueAtKeyPath)) { + return false + } + + return valueAtKeyPath.some((subItem) => this.predicate.matchesItem(subItem)) + } + + keypathIncludesString(verb: string): boolean { + return this.keypath.includes(verb) + } + + toJson(): PredicateJsonForm { + return { + keypath: this.keypath, + operator: 'includes', + value: this.predicate.toJson(), + } + } +} diff --git a/packages/models/src/Domain/Runtime/Predicate/Interface.ts b/packages/models/src/Domain/Runtime/Predicate/Interface.ts new file mode 100644 index 000000000..85c3c616f --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Interface.ts @@ -0,0 +1,45 @@ +export interface PredicateInterface { + matchesItem(item: T): boolean + keypathIncludesString(verb: string): boolean + toJson(): PredicateJsonForm +} + +export type RawPredicateInArrayForm = string[] + +export interface PredicateJsonForm { + keypath?: string + operator: PredicateOperator + value: SureValue | PredicateJsonForm | PredicateJsonForm[] | RawPredicateInArrayForm +} + +export const AllPredicateCompoundOperators = ['and', 'or'] as const +export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number] + +export const AllPredicateOperators = [ + ...AllPredicateCompoundOperators, + '!=', + '=', + '<', + '>', + '<=', + '>=', + 'startsWith', + 'in', + 'matches', + 'not', + 'includes', +] as const + +export type PredicateOperator = typeof AllPredicateOperators[number] + +export type SureValue = number | number[] | string[] | string | Date | boolean | false | '' + +export const SureValueNonObjectTypesAsStrings = ['number', 'string', 'boolean'] + +export type FalseyValue = false | '' | null | undefined + +export type PrimitiveOperand = SureValue | FalseyValue + +export type PredicateTarget = unknown + +export type StringKey = keyof T & string diff --git a/packages/models/src/Domain/Runtime/Predicate/NotPredicate.ts b/packages/models/src/Domain/Runtime/Predicate/NotPredicate.ts new file mode 100644 index 000000000..536192150 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/NotPredicate.ts @@ -0,0 +1,20 @@ +import { PredicateTarget, PredicateInterface, PredicateJsonForm } from './Interface' + +export class NotPredicate implements PredicateInterface { + constructor(public readonly predicate: PredicateInterface) {} + + matchesItem(item: T): boolean { + return !this.predicate.matchesItem(item) + } + + keypathIncludesString(verb: string): boolean { + return this.predicate.keypathIncludesString(verb) + } + + toJson(): PredicateJsonForm { + return { + operator: 'not', + value: this.predicate.toJson(), + } + } +} diff --git a/packages/models/src/Domain/Runtime/Predicate/Operator.ts b/packages/models/src/Domain/Runtime/Predicate/Operator.ts new file mode 100644 index 000000000..a1af4db6a --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Operator.ts @@ -0,0 +1,95 @@ +import { isString } from '@standardnotes/utils' +import { FalseyValue, PredicateOperator, PrimitiveOperand, SureValue } from './Interface' +import { dateFromDSLDateString } from './Utils' + +export function valueMatchesTargetValue( + value: PrimitiveOperand, + operator: PredicateOperator, + targetValue: SureValue, +): boolean { + if (targetValue == undefined) { + return false + } + + if (typeof targetValue === 'string' && targetValue.includes('.ago')) { + targetValue = dateFromDSLDateString(targetValue) + } + + if (typeof targetValue === 'string') { + targetValue = targetValue.toLowerCase() + } + + if (typeof value === 'string') { + value = value.toLowerCase() + } + + if (operator === 'not') { + return !valueMatchesTargetValue(value, '=', targetValue) + } + + const falseyValues = [false, '', null, undefined, NaN] + if (value == undefined) { + const isExpectingFalseyValue = falseyValues.includes(targetValue as FalseyValue) + if (operator === '!=') { + return !isExpectingFalseyValue + } else { + return isExpectingFalseyValue + } + } + + if (operator === '=') { + if (Array.isArray(value)) { + return JSON.stringify(value) === JSON.stringify(targetValue) + } else { + return value === targetValue + } + } + + if (operator === '!=') { + if (Array.isArray(value)) { + return JSON.stringify(value) !== JSON.stringify(targetValue) + } else { + return value !== targetValue + } + } + + if (operator === '<') { + return (value as number) < (targetValue as number) + } + + if (operator === '>') { + return (value as number) > (targetValue as number) + } + + if (operator === '<=') { + return (value as number) <= (targetValue as number) + } + + if (operator === '>=') { + return (value as number) >= (targetValue as number) + } + + if (operator === 'startsWith') { + return (value as string).startsWith(targetValue as string) + } + + if (operator === 'in' && Array.isArray(targetValue)) { + return (targetValue as SureValue[]).includes(value) + } + + if (operator === 'includes') { + if (isString(value)) { + return value.includes(targetValue as string) + } + + if (isString(targetValue) && (isString(value) || Array.isArray(value))) { + return (value as SureValue[]).includes(targetValue) + } + } + + if (operator === 'matches') { + const regex = new RegExp(targetValue as string) + return regex.test(value as string) + } + return false +} diff --git a/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts b/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts new file mode 100644 index 000000000..974d2d1f4 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts @@ -0,0 +1,639 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' +import { ContentType } from '@standardnotes/common' +import { + compoundPredicateFromArguments, + includesPredicateFromArguments, + notPredicateFromArguments, + predicateFromArguments, + predicateFromDSLString, +} from './Generators' +import { IncludesPredicate } from './IncludesPredicate' +import { Predicate } from './Predicate' +import { CompoundPredicate } from './CompoundPredicate' +import { NotPredicate } from './NotPredicate' + +interface Item extends ItemInterface { + content_type: ContentType + updated_at: Date +} + +interface Note extends Item { + title: string + text: string + tags: Tag[] +} + +interface Tag extends Item { + title: string +} + +function createNote(content: Record, tags?: Tag[]): Note { + return { + ...content, + content_type: ContentType.Note, + tags, + } as jest.Mocked +} + +function createTag(title: string): Tag { + return { + title, + content_type: ContentType.Tag, + } as jest.Mocked +} + +function createItem(content: Record, updatedAt?: Date): Item { + return { + ...content, + updated_at: updatedAt, + content_type: ContentType.Any, + } as jest.Mocked +} + +const createNoteContent = (title = 'Hello', desc = 'World') => { + const params = { + title: title, + text: desc, + } + return params +} + +const tags = [createTag('foo'), createTag('bar'), createTag('far')] + +describe('predicates', () => { + it('string comparisons should be case insensitive', () => { + const string = '!["Not notes", "title", "startsWith", "foo"]' + const predicate = predicateFromDSLString(string) + + const matchingItem1 = createTag('foo') + + expect(predicate.matchesItem(matchingItem1)).toEqual(true) + + const matchingItem2 = { + title: 'Foo', + } as jest.Mocked + + expect(predicate.matchesItem(matchingItem2)).toEqual(true) + }) + + describe('includes operator', () => { + let item: Note + beforeEach(() => { + item = createNote(createNoteContent(), tags) + }) + + it('includes string', () => { + expect(new Predicate('title', 'includes', 'ello').matchesItem(item)).toEqual(true) + }) + }) + + describe('or operator', () => { + let item: Note + const title = 'Hello' + beforeEach(() => { + item = createNote(createNoteContent(title)) + }) + + it('both matching', () => { + expect( + compoundPredicateFromArguments('or', [ + { keypath: 'title', operator: '=', value: 'Hello' }, + { keypath: 'content_type', operator: '=', value: ContentType.Note }, + ]).matchesItem(item), + ).toEqual(true) + }) + + it('first matching', () => { + expect( + compoundPredicateFromArguments('or', [ + { keypath: 'title', operator: '=', value: 'Hello' }, + { keypath: 'content_type', operator: '=', value: 'Wrong' }, + ]).matchesItem(item), + ).toEqual(true) + }) + + it('second matching', () => { + expect( + compoundPredicateFromArguments('or', [ + { keypath: 'title', operator: '=', value: 'Wrong' }, + { keypath: 'content_type', operator: '=', value: ContentType.Note }, + ]).matchesItem(item), + ).toEqual(true) + }) + + it('both nonmatching', () => { + expect( + compoundPredicateFromArguments('or', [ + { keypath: 'title', operator: '=', value: 'Wrong' }, + { keypath: 'content_type', operator: '=', value: 'Wrong' }, + ]).matchesItem(item), + ).toEqual(false) + }) + }) + + describe('includes operator', () => { + let item: Note + const title = 'Foo' + beforeEach(() => { + item = createNote(createNoteContent(title), tags) + }) + + it('all matching', () => { + const predicate = new IncludesPredicate('tags', new Predicate('title', 'in', ['sobar', 'foo'])) + + expect(predicate.matchesItem(item)).toEqual(true) + }) + }) + + describe('and operator', () => { + let item: Note + const title = 'Foo' + beforeEach(() => { + item = createNote(createNoteContent(title)) + }) + + it('all matching', () => { + expect( + compoundPredicateFromArguments('and', [ + { keypath: 'title', operator: '=', value: title }, + { keypath: 'content_type', operator: '=', value: ContentType.Note }, + ]).matchesItem(item), + ).toEqual(true) + }) + + it('one matching', () => { + expect( + compoundPredicateFromArguments('and', [ + { keypath: 'title', operator: '=', value: 'Wrong' }, + { keypath: 'content_type', operator: '=', value: ContentType.Note }, + ]).matchesItem(item), + ).toEqual(false) + }) + + it('none matching', () => { + expect( + compoundPredicateFromArguments('and', [ + { keypath: 'title', operator: '=', value: '123' }, + { keypath: 'content_type', operator: '=', value: '456' }, + ]).matchesItem(item), + ).toEqual(false) + }) + + it('explicit compound syntax', () => { + const compoundProd = new CompoundPredicate('and', [ + new Predicate('title', '=', title), + new Predicate('content_type', '=', ContentType.Note), + ]) + expect(compoundProd.matchesItem(item)).toEqual(true) + }) + }) + + describe('not operator', function () { + let item: Note + beforeEach(() => { + item = createNote(createNoteContent(), tags) + }) + + it('basic not predicate', () => { + expect( + new NotPredicate( + new IncludesPredicate('tags', new Predicate('title', '=', 'far')), + ).matchesItem(item), + ).toEqual(false) + }) + + it('recursive compound predicate', () => { + expect( + new CompoundPredicate('and', [ + new NotPredicate(new IncludesPredicate('tags', new Predicate('title', '=', 'far'))), + new IncludesPredicate('tags', new Predicate('title', '=', 'foo')), + ]).matchesItem(item), + ).toEqual(false) + }) + + it('matching basic operator', () => { + expect( + notPredicateFromArguments({ + keypath: 'title', + operator: '=', + value: 'Not This Title', + }).matchesItem(item), + ).toEqual(true) + }) + + it('nonmatching basic operator', () => { + expect( + notPredicateFromArguments({ + keypath: 'title', + operator: '=', + value: 'Hello', + }).matchesItem(item), + ).toEqual(false) + }) + + it('matching compound', () => { + expect( + new CompoundPredicate('and', [ + new NotPredicate(new IncludesPredicate('tags', new Predicate('title', '=', 'boo'))), + new IncludesPredicate('tags', new Predicate('title', '=', 'foo')), + ]).matchesItem(item), + ).toEqual(true) + }) + + it('matching compound includes', () => { + const andPredicate = new CompoundPredicate('and', [ + predicateFromArguments('title', 'startsWith', 'H'), + includesPredicateFromArguments('tags', { + keypath: 'title', + operator: '=', + value: 'falsify', + }), + ]) + expect(new NotPredicate(andPredicate).matchesItem(item)).toEqual(true) + }) + + it('nonmatching compound includes', () => { + expect( + new NotPredicate( + new CompoundPredicate('and', [ + new Predicate('title', 'startsWith', 'H'), + new IncludesPredicate('tags', new Predicate('title', '=', 'foo')), + ]), + ).matchesItem(item), + ).toEqual(false) + }) + + it('nonmatching compound or', () => { + expect( + new NotPredicate( + new CompoundPredicate('or', [ + new Predicate('title', 'startsWith', 'H'), + new IncludesPredicate('tags', new Predicate('title', '=', 'falsify')), + ]), + ).matchesItem(item), + ).toEqual(false) + }) + + it('matching compound or', () => { + expect( + new NotPredicate( + new CompoundPredicate('or', [ + new Predicate('title', 'startsWith', 'Z'), + new IncludesPredicate('tags', new Predicate('title', '=', 'falsify')), + ]), + ).matchesItem(item), + ).toEqual(true) + }) + }) + + describe('regex', () => { + it('matching', () => { + const item = createNote(createNoteContent('abc')) + const onlyLetters = new Predicate('title', 'matches', '^[a-zA-Z]+$') + expect(onlyLetters.matchesItem(item)).toEqual(true) + }) + + it('nonmatching', () => { + const item = createNote(createNoteContent('123')) + const onlyLetters = new Predicate('title', 'matches', '^[a-zA-Z]+$') + expect(onlyLetters.matchesItem(item)).toEqual(false) + }) + }) + + describe('deep recursion', () => { + let item: Note + const title = 'Hello' + beforeEach(() => { + item = createNote(createNoteContent(title)) + }) + + it('matching', () => { + expect( + new CompoundPredicate('and', [ + new Predicate('title', '=', 'Hello'), + new CompoundPredicate('or', [ + new Predicate('title', '=', 'Wrong'), + new Predicate('title', '=', 'Wrong again'), + new Predicate('title', '=', 'Hello'), + ]), + ]).matchesItem(item), + ).toEqual(true) + }) + + it('nonmatching', () => { + expect( + new CompoundPredicate('and', [ + new Predicate('title', '=', 'Hello'), + new CompoundPredicate('or', [ + new Predicate('title', '=', 'Wrong'), + new Predicate('title', '=', 'Wrong again'), + new Predicate('title', '=', 'All wrong'), + ]), + ]).matchesItem(item), + ).toEqual(false) + }) + }) + + describe('inequality operator', () => { + let item: Item + const body = 'Hello' + const numbers = ['1', '2', '3'] + + beforeEach(() => { + item = createItem({ body, numbers }) + }) + + it('matching', () => { + expect(new Predicate('body', '!=', 'NotBody').matchesItem(item)).toEqual(true) + }) + + it('nonmatching', () => { + expect(new Predicate('body', '!=', body).matchesItem(item)).toEqual(false) + }) + + it('matching array', () => { + expect(new Predicate('numbers', '!=', ['1']).matchesItem(item)).toEqual(true) + }) + + it('nonmatching array', () => { + expect(new Predicate('numbers', '!=', ['1', '2', '3']).matchesItem(item)).toEqual(false) + }) + }) + + describe('equals operator', () => { + let item: Item + const body = 'Hello' + const numbers = ['1', '2', '3'] + + beforeEach(() => { + item = createItem({ body, numbers }) + }) + + it('matching', () => { + expect(new Predicate('body', '=', body).matchesItem(item)).toEqual(true) + }) + + it('nonmatching', () => { + expect(new Predicate('body', '=', 'NotBody').matchesItem(item)).toEqual(false) + }) + + it('false and undefined should be equivalent', () => { + expect(new Predicate('undefinedProperty', '=', false).matchesItem(item)).toEqual(true) + }) + + it('nonmatching array', () => { + expect(new Predicate('numbers', '=', ['1']).matchesItem(item)).toEqual(false) + }) + + it('matching array', () => { + expect(new Predicate('numbers', '=', ['1', '2', '3']).matchesItem(item)).toEqual(true) + }) + + it('nested keypath', () => { + expect(new Predicate('numbers.length', '=', numbers.length).matchesItem(item)).toEqual(true) + }) + }) + + describe('date comparison', () => { + let item: Item + const date = new Date() + + beforeEach(() => { + item = createItem({}, date) + }) + + it('nonmatching date value', () => { + const date = new Date() + date.setSeconds(date.getSeconds() + 1) + const predicate = new Predicate('updated_at', '>', date) + expect(predicate.matchesItem(item)).toEqual(false) + }) + + it('matching date value', () => { + const date = new Date() + date.setSeconds(date.getSeconds() + 1) + const predicate = new Predicate('updated_at', '<', date) + expect(predicate.matchesItem(item)).toEqual(true) + }) + + it('matching days ago value', () => { + expect(new Predicate('updated_at', '>', '30.days.ago').matchesItem(item)).toEqual(true) + }) + + it('nonmatching days ago value', () => { + expect(new Predicate('updated_at', '<', '30.days.ago').matchesItem(item)).toEqual(false) + }) + + it('hours ago value', () => { + expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true) + }) + }) + + describe('nonexistent properties', () => { + let item: Item + + beforeEach(() => { + item = createItem({}) + }) + + it('nested keypath', () => { + expect(new Predicate('foobar.length', '=', 0).matchesItem(item)).toEqual(false) + }) + + it('inequality operator', () => { + expect(new Predicate('foobar', '!=', 'NotFoo').matchesItem(item)).toEqual(true) + }) + + it('equals operator', () => { + expect(new Predicate('foobar', '=', 'NotFoo').matchesItem(item)).toEqual(false) + }) + + it('less than operator', () => { + expect(new Predicate('foobar', '<', 3).matchesItem(item)).toEqual(false) + }) + + it('greater than operator', () => { + expect(new Predicate('foobar', '>', 3).matchesItem(item)).toEqual(false) + }) + + it('less than or equal to operator', () => { + expect(new Predicate('foobar', '<=', 3).matchesItem(item)).toEqual(false) + }) + + it('includes operator', () => { + expect(new Predicate('foobar', 'includes', 3).matchesItem(item)).toEqual(false) + }) + }) + + describe('toJson', () => { + it('basic predicate', () => { + const predicate = new Predicate('title', 'startsWith', 'H') + const json = predicate.toJson() + + expect(json).toStrictEqual({ + keypath: 'title', + operator: 'startsWith', + value: 'H', + }) + }) + + it('compound and', () => { + const predicate = new CompoundPredicate('and', [ + new Predicate('title', 'startsWith', 'H'), + new Predicate('title', '=', 'Hello'), + ]) + const json = predicate.toJson() + + expect(json).toStrictEqual({ + operator: 'and', + value: [ + { + keypath: 'title', + operator: 'startsWith', + value: 'H', + }, + { + keypath: 'title', + operator: '=', + value: 'Hello', + }, + ], + }) + }) + + it('not', () => { + const predicate = new NotPredicate(new Predicate('title', 'startsWith', 'H')) + const json = predicate.toJson() + + expect(json).toStrictEqual({ + operator: 'not', + value: { + keypath: 'title', + operator: 'startsWith', + value: 'H', + }, + }) + }) + + it('not compound', () => { + const predicate = new NotPredicate( + new CompoundPredicate('or', [ + new Predicate('title', 'startsWith', 'H'), + new IncludesPredicate('tags', new Predicate('title', '=', 'falsify')), + ]), + ) + + const json = predicate.toJson() + + expect(json).toStrictEqual({ + operator: 'not', + value: { + operator: 'or', + value: [ + { + keypath: 'title', + operator: 'startsWith', + value: 'H', + }, + { + keypath: 'tags', + operator: 'includes', + value: { + keypath: 'title', + operator: '=', + value: 'falsify', + }, + }, + ], + }, + }) + }) + }) + + describe('generators', () => { + it('includes predicate', () => { + const json = ['B-tags', 'tags', 'includes', ['title', 'startsWith', 'b']] + const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as IncludesPredicate + + expect(predicate).toBeInstanceOf(IncludesPredicate) + expect(predicate.predicate).toBeInstanceOf(Predicate) + expect((predicate.predicate as Predicate).keypath).toEqual('title') + expect((predicate.predicate as Predicate).operator).toEqual('startsWith') + }) + + it('includes string should be mapped to normal predicate', () => { + const json = ['TODO', 'title', 'includes', 'TODO'] + const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as Predicate + + expect(predicate).toBeInstanceOf(Predicate) + expect(predicate.keypath).toEqual('title') + expect(predicate.operator).toEqual('includes') + }) + + it('complex compound and', () => { + const json = [ + 'label', + 'ignored_keypath', + 'and', + [ + ['', 'not', ['tags', 'includes', ['title', '=', 'boo']]], + ['tags', 'includes', ['title', '=', 'foo']], + ], + ] + + const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as CompoundPredicate + + expect(predicate).toBeInstanceOf(CompoundPredicate) + + expect(predicate.predicates).toHaveLength(2) + + const notPredicate = predicate.predicates[0] as NotPredicate + expect(notPredicate).toBeInstanceOf(NotPredicate) + + const includesPredicate = predicate.predicates[1] + expect(includesPredicate).toBeInstanceOf(IncludesPredicate) + + expect(notPredicate.predicate).toBeInstanceOf(IncludesPredicate) + expect((notPredicate.predicate as IncludesPredicate).predicate).toBeInstanceOf(Predicate) + }) + + it('nested compound or', () => { + const json = [ + 'label', + 'ignored_keypath', + 'and', + [ + ['title', '=', 'Hello'], + [ + 'this_field_ignored', + 'or', + [ + ['title', '=', 'Wrong'], + ['title', '=', 'Wrong again'], + ['title', '=', 'All wrong'], + ], + ], + ], + ] + + const predicate = predicateFromDSLString('!' + JSON.stringify(json)) as CompoundPredicate + + expect(predicate).toBeInstanceOf(CompoundPredicate) + + expect(predicate.predicates).toHaveLength(2) + + expect(predicate.predicates[0]).toBeInstanceOf(Predicate) + + const orPredicate = predicate.predicates[1] as CompoundPredicate + expect(orPredicate).toBeInstanceOf(CompoundPredicate) + expect(orPredicate.predicates).toHaveLength(3) + expect(orPredicate.operator).toEqual('or') + + for (const subPredicate of orPredicate.predicates) { + expect(subPredicate).toBeInstanceOf(Predicate) + } + }) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Predicate/Predicate.ts b/packages/models/src/Domain/Runtime/Predicate/Predicate.ts new file mode 100644 index 000000000..6da4d7ca7 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Predicate.ts @@ -0,0 +1,49 @@ +import { + PredicateTarget, + PredicateInterface, + PredicateJsonForm, + PredicateOperator, + PrimitiveOperand, + StringKey, + SureValue, +} from './Interface' +import { valueMatchesTargetValue } from './Operator' + +/** + * A local-only construct that defines a built query that + * can be used to dynamically search items. + */ +export class Predicate implements PredicateInterface { + constructor( + public readonly keypath: StringKey, + public readonly operator: PredicateOperator, + public readonly targetValue: SureValue, + ) { + if (this.targetValue === 'true' || this.targetValue === 'false') { + this.targetValue = JSON.parse(this.targetValue) + } + } + + keypathIncludesString(verb: string): boolean { + return (this.keypath as string).includes(verb) + } + + matchesItem(item: T): boolean { + const keyPathComponents = this.keypath.split('.') as StringKey[] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const valueAtKeyPath: PrimitiveOperand = keyPathComponents.reduce((previous, current) => { + return previous && previous[current] + }, item) + + return valueMatchesTargetValue(valueAtKeyPath, this.operator, this.targetValue) + } + + toJson(): PredicateJsonForm { + return { + keypath: this.keypath, + operator: this.operator, + value: this.targetValue, + } + } +} diff --git a/packages/models/src/Domain/Runtime/Predicate/Utils.ts b/packages/models/src/Domain/Runtime/Predicate/Utils.ts new file mode 100644 index 000000000..5c6b737fb --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Utils.ts @@ -0,0 +1,15 @@ +/** + * Predicate date strings are of form "x.days.ago" or "x.hours.ago" + */ +export function dateFromDSLDateString(string: string): Date { + const comps = string.split('.') + const unit = comps[1] + const date = new Date() + const offset = parseInt(comps[0]) + if (unit === 'days') { + date.setDate(date.getDate() - offset) + } else if (unit === 'hours') { + date.setHours(date.getHours() - offset) + } + return date +} diff --git a/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtension.ts b/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtension.ts new file mode 100644 index 000000000..6212464b4 --- /dev/null +++ b/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtension.ts @@ -0,0 +1,72 @@ +import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem' +import { ThirdPartyFeatureDescription } from '@standardnotes/features' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { HistoryEntryInterface } from '../../Runtime/History/HistoryEntryInterface' +import { Action } from './Types' +import { ComponentPackageInfo } from '../Component/PackageInfo' + +export interface ActionExtensionInterface { + actions: Action[] + deprecation?: string + description: string + hosted_url?: string + name: string + package_info: ComponentPackageInfo + supported_types: string[] + url: string +} + +export type ActionExtensionContent = ActionExtensionInterface & ItemContent + +/** + * Related to the SNActionsService and the local Action model. + */ +export class SNActionsExtension extends DecryptedItem { + public readonly actions: Action[] = [] + public readonly description: string + public readonly url: string + public readonly supported_types: string[] + public readonly deprecation?: string + public readonly name: string + public readonly package_info: ComponentPackageInfo + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.name = payload.content.name || '' + this.description = payload.content.description || '' + this.url = payload.content.hosted_url || payload.content.url + this.supported_types = payload.content.supported_types + this.package_info = this.payload.content.package_info || {} + this.deprecation = payload.content.deprecation + this.actions = payload.content.actions + } + + public get displayName(): string { + return this.name + } + + public get thirdPartyPackageInfo(): ThirdPartyFeatureDescription { + return this.package_info as ThirdPartyFeatureDescription + } + + public get isListedExtension(): boolean { + return (this.package_info.identifier as string) === 'org.standardnotes.listed' + } + + actionsWithContextForItem(item: DecryptedItemInterface): Action[] { + return this.actions.filter((action) => { + return action.context === item.content_type || action.context === 'Item' + }) + } + + /** Do not duplicate. Always keep original */ + override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } +} diff --git a/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtensionMutator.ts b/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtensionMutator.ts new file mode 100644 index 000000000..c730d3f90 --- /dev/null +++ b/packages/models/src/Domain/Syncable/ActionsExtension/ActionsExtensionMutator.ts @@ -0,0 +1,21 @@ +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { ActionExtensionContent } from './ActionsExtension' +import { Action } from './Types' + +export class ActionsExtensionMutator extends DecryptedItemMutator { + set description(description: string) { + this.mutableContent.description = description + } + + set supported_types(supported_types: string[]) { + this.mutableContent.supported_types = supported_types + } + + set actions(actions: Action[]) { + this.mutableContent.actions = actions + } + + set deprecation(deprecation: string | undefined) { + this.mutableContent.deprecation = deprecation + } +} diff --git a/packages/models/src/Domain/Syncable/ActionsExtension/Types.ts b/packages/models/src/Domain/Syncable/ActionsExtension/Types.ts new file mode 100644 index 000000000..bfdf8907b --- /dev/null +++ b/packages/models/src/Domain/Syncable/ActionsExtension/Types.ts @@ -0,0 +1,25 @@ +export enum ActionAccessType { + Encrypted = 'encrypted', + Decrypted = 'decrypted', +} + +export enum ActionVerb { + Get = 'get', + Render = 'render', + Show = 'show', + Post = 'post', + Nested = 'nested', +} + +export type Action = { + label: string + desc: string + running?: boolean + error?: boolean + lastExecuted?: Date + context?: string + verb: ActionVerb + url: string + access_type: ActionAccessType + subactions?: Action[] +} diff --git a/packages/models/src/Domain/Syncable/ActionsExtension/index.ts b/packages/models/src/Domain/Syncable/ActionsExtension/index.ts new file mode 100644 index 000000000..eed2bc3f0 --- /dev/null +++ b/packages/models/src/Domain/Syncable/ActionsExtension/index.ts @@ -0,0 +1,3 @@ +export * from './ActionsExtension' +export * from './ActionsExtensionMutator' +export * from './Types' diff --git a/packages/models/src/Domain/Syncable/Component/Component.spec.ts b/packages/models/src/Domain/Syncable/Component/Component.spec.ts new file mode 100644 index 000000000..68ccc5399 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/Component.spec.ts @@ -0,0 +1,49 @@ +import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource' +import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload' +import { ContentType } from '@standardnotes/common' +import { FillItemContent } from '../../Abstract/Content/ItemContent' +import { SNComponent } from './Component' +import { ComponentContent } from './ComponentContent' +import { PayloadTimestampDefaults } from '../../Abstract/Payload' + +describe('component model', () => { + it('valid hosted url should ignore url', () => { + const component = new SNComponent( + new DecryptedPayload( + { + uuid: String(Math.random()), + content_type: ContentType.Component, + content: FillItemContent({ + url: 'http://foo.com', + hosted_url: 'http://bar.com', + } as ComponentContent), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) + + expect(component.hasValidHostedUrl()).toBe(true) + expect(component.hosted_url).toBe('http://bar.com') + }) + + it('invalid hosted url should fallback to url', () => { + const component = new SNComponent( + new DecryptedPayload( + { + uuid: String(Math.random()), + content_type: ContentType.Component, + content: FillItemContent({ + url: 'http://foo.com', + hosted_url: '#{foo.zoo}', + } as ComponentContent), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) + + expect(component.hasValidHostedUrl()).toBe(true) + expect(component.hosted_url).toBe('http://foo.com') + }) +}) diff --git a/packages/models/src/Domain/Syncable/Component/Component.ts b/packages/models/src/Domain/Syncable/Component/Component.ts new file mode 100644 index 000000000..595043d14 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/Component.ts @@ -0,0 +1,189 @@ +import { isValidUrl } from '@standardnotes/utils' +import { ContentType, Uuid } from '@standardnotes/common' +import { + FeatureIdentifier, + ThirdPartyFeatureDescription, + ComponentArea, + ComponentFlag, + ComponentPermission, + FindNativeFeature, +} from '@standardnotes/features' +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { ComponentContent, ComponentInterface } from './ComponentContent' +import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { HistoryEntryInterface } from '../../Runtime/History' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { Predicate } from '../../Runtime/Predicate/Predicate' +import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' +import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem' +import { ComponentPackageInfo } from './PackageInfo' + +export const isComponent = (x: ItemInterface): x is SNComponent => x.content_type === ContentType.Component + +export const isComponentOrTheme = (x: ItemInterface): x is SNComponent => + x.content_type === ContentType.Component || x.content_type === ContentType.Theme + +/** + * Components are mostly iframe based extensions that communicate with the SN parent + * via the postMessage API. However, a theme can also be a component, which is activated + * only by its url. + */ +export class SNComponent extends DecryptedItem implements ComponentInterface { + public readonly componentData: Record + /** Items that have requested a component to be disabled in its context */ + public readonly disassociatedItemIds: string[] + /** Items that have requested a component to be enabled in its context */ + public readonly associatedItemIds: string[] + public readonly local_url?: string + public readonly hosted_url?: string + public readonly offlineOnly: boolean + public readonly name: string + public readonly autoupdateDisabled: boolean + public readonly package_info: ComponentPackageInfo + public readonly area: ComponentArea + public readonly permissions: ComponentPermission[] = [] + public readonly valid_until: Date + public readonly active: boolean + public readonly legacy_url?: string + public readonly isMobileDefault: boolean + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + /** Custom data that a component can store in itself */ + this.componentData = this.payload.content.componentData || {} + + if (payload.content.hosted_url && isValidUrl(payload.content.hosted_url)) { + this.hosted_url = payload.content.hosted_url + } else if (payload.content.url && isValidUrl(payload.content.url)) { + this.hosted_url = payload.content.url + } else if (payload.content.legacy_url && isValidUrl(payload.content.legacy_url)) { + this.hosted_url = payload.content.legacy_url + } + this.local_url = payload.content.local_url + + this.valid_until = new Date(payload.content.valid_until || 0) + this.offlineOnly = payload.content.offlineOnly + this.name = payload.content.name + this.area = payload.content.area + this.package_info = payload.content.package_info || {} + this.permissions = payload.content.permissions || [] + this.active = payload.content.active + this.autoupdateDisabled = payload.content.autoupdateDisabled + this.disassociatedItemIds = payload.content.disassociatedItemIds || [] + this.associatedItemIds = payload.content.associatedItemIds || [] + this.isMobileDefault = payload.content.isMobileDefault + /** + * @legacy + * We don't want to set this.url directly, as we'd like to phase it out. + * If the content.url exists, we'll transfer it to legacy_url. We'll only + * need to set this if content.hosted_url is blank, otherwise, + * hosted_url is the url replacement. + */ + this.legacy_url = !payload.content.hosted_url ? payload.content.url : undefined + } + + /** Do not duplicate components under most circumstances. Always keep original */ + public override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } + + override get isSingleton(): boolean { + return true + } + + public get displayName(): string { + return FindNativeFeature(this.identifier)?.name || this.name + } + + public override singletonPredicate(): Predicate { + const uniqueIdentifierPredicate = new Predicate('identifier', '=', this.identifier) + return uniqueIdentifierPredicate + } + + public isEditor(): boolean { + return this.area === ComponentArea.Editor + } + + public isTheme(): boolean { + return this.content_type === ContentType.Theme || this.area === ComponentArea.Themes + } + + public isDefaultEditor(): boolean { + return this.getAppDomainValue(AppDataField.DefaultEditor) === true + } + + public getLastSize(): unknown { + return this.getAppDomainValue(AppDataField.LastSize) + } + + /** + * The key used to look up data that this component may have saved to an item. + * This data will be stored on the item using this key. + */ + public getClientDataKey(): string { + if (this.legacy_url) { + return this.legacy_url + } else { + return this.uuid + } + } + + public hasValidHostedUrl(): boolean { + return (this.hosted_url || this.legacy_url) != undefined + } + + public override contentKeysToIgnoreWhenCheckingEquality(): (keyof ItemContent)[] { + const componentKeys: (keyof ComponentContent)[] = ['active', 'disassociatedItemIds', 'associatedItemIds'] + + const superKeys = super.contentKeysToIgnoreWhenCheckingEquality() + return [...componentKeys, ...superKeys] as (keyof ItemContent)[] + } + + /** + * An associative component depends on being explicitly activated for a + * given item, compared to a dissaciative component, which is enabled by + * default in areas unrelated to a certain item. + */ + public static associativeAreas(): ComponentArea[] { + return [ComponentArea.Editor] + } + + public isAssociative(): boolean { + return SNComponent.associativeAreas().includes(this.area) + } + + public isExplicitlyEnabledForItem(uuid: Uuid): boolean { + return this.associatedItemIds.indexOf(uuid) !== -1 + } + + public isExplicitlyDisabledForItem(uuid: Uuid): boolean { + return this.disassociatedItemIds.indexOf(uuid) !== -1 + } + + public get isExpired(): boolean { + return this.valid_until.getTime() > 0 && this.valid_until <= new Date() + } + + public get identifier(): FeatureIdentifier { + return this.package_info.identifier + } + + public get thirdPartyPackageInfo(): ThirdPartyFeatureDescription { + return this.package_info as ThirdPartyFeatureDescription + } + + public get isDeprecated(): boolean { + let flags: string[] = this.package_info.flags ?? [] + flags = flags.map((flag: string) => flag.toLowerCase()) + return flags.includes(ComponentFlag.Deprecated) + } + + public get deprecationMessage(): string | undefined { + return this.package_info.deprecation_message + } +} diff --git a/packages/models/src/Domain/Syncable/Component/ComponentContent.ts b/packages/models/src/Domain/Syncable/Component/ComponentContent.ts new file mode 100644 index 000000000..9e6787e42 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/ComponentContent.ts @@ -0,0 +1,36 @@ +import { ComponentArea, ComponentPermission } from '@standardnotes/features' +import { Uuid } from '@standardnotes/common' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { ComponentPackageInfo } from './PackageInfo' + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface ComponentInterface { + componentData: Record + + /** Items that have requested a component to be disabled in its context */ + disassociatedItemIds: string[] + + /** Items that have requested a component to be enabled in its context */ + associatedItemIds: string[] + + local_url?: string + hosted_url?: string + + /** @deprecated */ + url?: string + + offlineOnly: boolean + name: string + autoupdateDisabled: boolean + package_info: ComponentPackageInfo + area: ComponentArea + permissions: ComponentPermission[] + valid_until: Date | number + active: boolean + legacy_url?: string + isMobileDefault: boolean + isDeprecated: boolean + isExplicitlyEnabledForItem(uuid: Uuid): boolean +} + +export type ComponentContent = ComponentInterface & ItemContent diff --git a/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts b/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts new file mode 100644 index 000000000..9b24c8aac --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/ComponentMutator.ts @@ -0,0 +1,76 @@ +import { addIfUnique, removeFromArray } from '@standardnotes/utils' +import { Uuid } from '@standardnotes/common' +import { ComponentPermission, FeatureDescription } from '@standardnotes/features' +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { ComponentContent } from './ComponentContent' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +export class ComponentMutator extends DecryptedItemMutator { + set active(active: boolean) { + this.mutableContent.active = active + } + + set isMobileDefault(isMobileDefault: boolean) { + this.mutableContent.isMobileDefault = isMobileDefault + } + + set defaultEditor(defaultEditor: boolean) { + this.setAppDataItem(AppDataField.DefaultEditor, defaultEditor) + } + + set componentData(componentData: Record) { + this.mutableContent.componentData = componentData + } + + set package_info(package_info: FeatureDescription) { + this.mutableContent.package_info = package_info + } + + set local_url(local_url: string) { + this.mutableContent.local_url = local_url + } + + set hosted_url(hosted_url: string) { + this.mutableContent.hosted_url = hosted_url + } + + set valid_until(valid_until: Date) { + this.mutableContent.valid_until = valid_until + } + + set permissions(permissions: ComponentPermission[]) { + this.mutableContent.permissions = permissions + } + + set name(name: string) { + this.mutableContent.name = name + } + + set offlineOnly(offlineOnly: boolean) { + this.mutableContent.offlineOnly = offlineOnly + } + + public associateWithItem(uuid: Uuid): void { + const associated = this.mutableContent.associatedItemIds || [] + addIfUnique(associated, uuid) + this.mutableContent.associatedItemIds = associated + } + + public disassociateWithItem(uuid: Uuid): void { + const disassociated = this.mutableContent.disassociatedItemIds || [] + addIfUnique(disassociated, uuid) + this.mutableContent.disassociatedItemIds = disassociated + } + + public removeAssociatedItemId(uuid: Uuid): void { + removeFromArray(this.mutableContent.associatedItemIds || [], uuid) + } + + public removeDisassociatedItemId(uuid: Uuid): void { + removeFromArray(this.mutableContent.disassociatedItemIds || [], uuid) + } + + public setLastSize(size: string): void { + this.setAppDataItem(AppDataField.LastSize, size) + } +} diff --git a/packages/models/src/Domain/Syncable/Component/PackageInfo.ts b/packages/models/src/Domain/Syncable/Component/PackageInfo.ts new file mode 100644 index 000000000..6f9b1820a --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/PackageInfo.ts @@ -0,0 +1,8 @@ +import { FeatureDescription } from '@standardnotes/features' + +type ThirdPartyPackageInfo = { + version: string + download_url?: string +} + +export type ComponentPackageInfo = FeatureDescription & Partial diff --git a/packages/models/src/Domain/Syncable/Component/index.ts b/packages/models/src/Domain/Syncable/Component/index.ts new file mode 100644 index 000000000..6cbb1cc10 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Component/index.ts @@ -0,0 +1,3 @@ +export * from './Component' +export * from './ComponentMutator' +export * from './ComponentContent' diff --git a/packages/models/src/Domain/Syncable/Editor/Editor.ts b/packages/models/src/Domain/Syncable/Editor/Editor.ts new file mode 100644 index 000000000..9f748d9f5 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Editor/Editor.ts @@ -0,0 +1,35 @@ +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { SNNote } from '../Note/Note' + +interface EditorContent extends ItemContent { + notes: SNNote[] + data: Record + url: string + name: string + default: boolean + systemEditor: boolean +} + +/** + * @deprecated + * Editor objects are depracated in favor of SNComponent objects + */ +export class SNEditor extends DecryptedItem { + public readonly notes: SNNote[] = [] + public readonly data: Record = {} + public readonly url: string + public readonly name: string + public readonly isDefault: boolean + public readonly systemEditor: boolean + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.url = payload.content.url + this.name = payload.content.name + this.data = payload.content.data || {} + this.isDefault = payload.content.default + this.systemEditor = payload.content.systemEditor + } +} diff --git a/packages/models/src/Domain/Syncable/Editor/index.ts b/packages/models/src/Domain/Syncable/Editor/index.ts new file mode 100644 index 000000000..740c04420 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Editor/index.ts @@ -0,0 +1 @@ +export * from './Editor' diff --git a/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepo.ts b/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepo.ts new file mode 100644 index 000000000..5db7cc04b --- /dev/null +++ b/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepo.ts @@ -0,0 +1,33 @@ +import { useBoolean } from '@standardnotes/utils' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemContent } from '../../Abstract/Content/ItemContent' + +export interface FeatureRepoContent extends ItemContent { + migratedToUserSetting?: boolean + migratedToOfflineEntitlements?: boolean + offlineFeaturesUrl?: string + offlineKey?: string + url?: string +} + +export class SNFeatureRepo extends DecryptedItem { + public get migratedToUserSetting(): boolean { + return useBoolean(this.payload.content.migratedToUserSetting, false) + } + + public get migratedToOfflineEntitlements(): boolean { + return useBoolean(this.payload.content.migratedToOfflineEntitlements, false) + } + + public get onlineUrl(): string | undefined { + return this.payload.content.url + } + + get offlineFeaturesUrl(): string | undefined { + return this.payload.content.offlineFeaturesUrl + } + + get offlineKey(): string | undefined { + return this.payload.content.offlineKey + } +} diff --git a/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepoMutator.ts b/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepoMutator.ts new file mode 100644 index 000000000..a5ce25545 --- /dev/null +++ b/packages/models/src/Domain/Syncable/FeatureRepo/FeatureRepoMutator.ts @@ -0,0 +1,20 @@ +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { FeatureRepoContent } from './FeatureRepo' + +export class FeatureRepoMutator extends DecryptedItemMutator { + set migratedToUserSetting(migratedToUserSetting: boolean) { + this.mutableContent.migratedToUserSetting = migratedToUserSetting + } + + set migratedToOfflineEntitlements(migratedToOfflineEntitlements: boolean) { + this.mutableContent.migratedToOfflineEntitlements = migratedToOfflineEntitlements + } + + set offlineFeaturesUrl(offlineFeaturesUrl: string) { + this.mutableContent.offlineFeaturesUrl = offlineFeaturesUrl + } + + set offlineKey(offlineKey: string) { + this.mutableContent.offlineKey = offlineKey + } +} diff --git a/packages/models/src/Domain/Syncable/FeatureRepo/index.ts b/packages/models/src/Domain/Syncable/FeatureRepo/index.ts new file mode 100644 index 000000000..655bd8dd9 --- /dev/null +++ b/packages/models/src/Domain/Syncable/FeatureRepo/index.ts @@ -0,0 +1,2 @@ +export * from './FeatureRepo' +export * from './FeatureRepoMutator' diff --git a/packages/models/src/Domain/Syncable/File/File.spec.ts b/packages/models/src/Domain/Syncable/File/File.spec.ts new file mode 100644 index 000000000..466f6b019 --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/File.spec.ts @@ -0,0 +1,75 @@ +import { ConflictStrategy } from './../../Abstract/Item/Types/ConflictStrategy' +import { ContentType } from '@standardnotes/common' +import { FillItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload' +import { FileContent, FileItem } from './File' +import { UuidGenerator } from '@standardnotes/utils' + +UuidGenerator.SetGenerator(() => String(Math.random())) + +describe('file', () => { + const createFile = (content: Partial = {}): FileItem => { + return new FileItem( + new DecryptedPayload({ + uuid: '123', + content_type: ContentType.File, + content: FillItemContent({ + name: 'name.png', + key: 'secret', + remoteIdentifier: 'A', + encryptionHeader: 'header', + encryptedChunkSizes: [1, 2, 3], + ...content, + }), + dirty: true, + ...PayloadTimestampDefaults(), + }), + ) + } + + const copyFile = (file: FileItem, override: Partial = {}): FileItem => { + return new FileItem( + file.payload.copy({ + content: { + ...file.content, + ...override, + } as FileContent, + }), + ) + } + + it('should not copy on name conflict', () => { + const file = createFile({ name: 'file.png' }) + const conflictedFile = copyFile(file, { name: 'different.png' }) + + expect(file.strategyWhenConflictingWithItem(conflictedFile)).toEqual(ConflictStrategy.KeepBase) + }) + + it('should copy on key conflict', () => { + const file = createFile({ name: 'file.png' }) + const conflictedFile = copyFile(file, { key: 'different-secret' }) + + expect(file.strategyWhenConflictingWithItem(conflictedFile)).toEqual(ConflictStrategy.KeepBaseDuplicateApply) + }) + + it('should copy on header conflict', () => { + const file = createFile({ name: 'file.png' }) + const conflictedFile = copyFile(file, { encryptionHeader: 'different-header' }) + + expect(file.strategyWhenConflictingWithItem(conflictedFile)).toEqual(ConflictStrategy.KeepBaseDuplicateApply) + }) + + it('should copy on identifier conflict', () => { + const file = createFile({ name: 'file.png' }) + const conflictedFile = copyFile(file, { remoteIdentifier: 'different-identifier' }) + + expect(file.strategyWhenConflictingWithItem(conflictedFile)).toEqual(ConflictStrategy.KeepBaseDuplicateApply) + }) + + it('should copy on chunk sizes conflict', () => { + const file = createFile({ name: 'file.png' }) + const conflictedFile = copyFile(file, { encryptedChunkSizes: [10, 9, 8] }) + + expect(file.strategyWhenConflictingWithItem(conflictedFile)).toEqual(ConflictStrategy.KeepBaseDuplicateApply) + }) +}) diff --git a/packages/models/src/Domain/Syncable/File/File.ts b/packages/models/src/Domain/Syncable/File/File.ts new file mode 100644 index 000000000..0a79acc81 --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/File.ts @@ -0,0 +1,85 @@ +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { FileMetadata } from './FileMetadata' +import { FileProtocolV1 } from './FileProtocolV1' +import { SortableItem } from '../../Runtime/Collection/CollectionSort' +import { ConflictStrategy } from '../../Abstract/Item' + +type EncryptedBytesLength = number +type DecryptedBytesLength = number + +interface SizesDeprecatedDueToAmbiguousNaming { + size?: DecryptedBytesLength + chunkSizes?: EncryptedBytesLength[] +} + +interface Sizes { + decryptedSize: DecryptedBytesLength + encryptedChunkSizes: EncryptedBytesLength[] +} + +interface FileContentWithoutSize { + remoteIdentifier: string + name: string + key: string + encryptionHeader: string + mimeType: string +} + +export type FileContentSpecialized = FileContentWithoutSize & FileMetadata & SizesDeprecatedDueToAmbiguousNaming & Sizes + +export type FileContent = FileContentSpecialized & ItemContent + +export class FileItem + extends DecryptedItem + implements FileContentWithoutSize, Sizes, FileProtocolV1, FileMetadata, SortableItem +{ + public readonly remoteIdentifier: string + public readonly name: string + public readonly key: string + public readonly encryptionHeader: string + public readonly mimeType: string + + public readonly decryptedSize: DecryptedBytesLength + public readonly encryptedChunkSizes: EncryptedBytesLength[] + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.remoteIdentifier = this.content.remoteIdentifier + this.name = this.content.name + this.key = this.content.key + + if (this.content.size && this.content.chunkSizes) { + this.decryptedSize = this.content.size + this.encryptedChunkSizes = this.content.chunkSizes + } else { + this.decryptedSize = this.content.decryptedSize + this.encryptedChunkSizes = this.content.encryptedChunkSizes + } + + this.encryptionHeader = this.content.encryptionHeader + this.mimeType = this.content.mimeType + } + + public override strategyWhenConflictingWithItem(item: FileItem): ConflictStrategy { + if ( + item.key !== this.key || + item.encryptionHeader !== this.encryptionHeader || + item.remoteIdentifier !== this.remoteIdentifier || + JSON.stringify(item.encryptedChunkSizes) !== JSON.stringify(this.encryptedChunkSizes) + ) { + return ConflictStrategy.KeepBaseDuplicateApply + } + + return ConflictStrategy.KeepBase + } + + public get encryptedSize(): number { + return this.encryptedChunkSizes.reduce((total, chunk) => total + chunk, 0) + } + + public get title(): string { + return this.name + } +} diff --git a/packages/models/src/Domain/Syncable/File/FileMetadata.ts b/packages/models/src/Domain/Syncable/File/FileMetadata.ts new file mode 100644 index 000000000..11a55799a --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/FileMetadata.ts @@ -0,0 +1,4 @@ +export interface FileMetadata { + name: string + mimeType: string +} diff --git a/packages/models/src/Domain/Syncable/File/FileMutator.ts b/packages/models/src/Domain/Syncable/File/FileMutator.ts new file mode 100644 index 000000000..93b834845 --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/FileMutator.ts @@ -0,0 +1,33 @@ +import { ContentType } from '@standardnotes/common' +import { SNNote } from '../Note/Note' +import { FileContent } from './File' +import { FileToNoteReference } from '../../Abstract/Reference/FileToNoteReference' +import { ContenteReferenceType } from '../../Abstract/Reference/ContenteReferenceType' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +export class FileMutator extends DecryptedItemMutator { + set name(newName: string) { + this.mutableContent.name = newName + } + + set encryptionHeader(encryptionHeader: string) { + this.mutableContent.encryptionHeader = encryptionHeader + } + + public addNote(note: SNNote): void { + const reference: FileToNoteReference = { + reference_type: ContenteReferenceType.FileToNote, + content_type: ContentType.Note, + uuid: note.uuid, + } + + const references = this.mutableContent.references || [] + references.push(reference) + this.mutableContent.references = references + } + + public removeNote(note: SNNote): void { + const references = this.immutableItem.references.filter((ref) => ref.uuid !== note.uuid) + this.mutableContent.references = references + } +} diff --git a/packages/models/src/Domain/Syncable/File/FileProtocolV1.ts b/packages/models/src/Domain/Syncable/File/FileProtocolV1.ts new file mode 100644 index 000000000..815de07c9 --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/FileProtocolV1.ts @@ -0,0 +1,9 @@ +export interface FileProtocolV1 { + readonly encryptionHeader: string + readonly key: string + readonly remoteIdentifier: string +} + +export enum FileProtocolV1Constants { + KeySize = 256, +} diff --git a/packages/models/src/Domain/Syncable/File/index.ts b/packages/models/src/Domain/Syncable/File/index.ts new file mode 100644 index 000000000..180bba421 --- /dev/null +++ b/packages/models/src/Domain/Syncable/File/index.ts @@ -0,0 +1,4 @@ +export * from './File' +export * from './FileMutator' +export * from './FileMetadata' +export * from './FileProtocolV1' diff --git a/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyInterface.ts b/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyInterface.ts new file mode 100644 index 000000000..6271147e9 --- /dev/null +++ b/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyInterface.ts @@ -0,0 +1,19 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem' +import { ItemContent, SpecializedContent } from '../../Abstract/Content/ItemContent' + +export interface ItemsKeyContentSpecialized extends SpecializedContent { + version: ProtocolVersion + isDefault?: boolean | undefined + itemsKey: string + dataAuthenticationKey?: string +} + +export type ItemsKeyContent = ItemsKeyContentSpecialized & ItemContent + +export interface ItemsKeyInterface extends DecryptedItemInterface { + get keyVersion(): ProtocolVersion + get isDefault(): boolean | undefined + get itemsKey(): string + get dataAuthenticationKey(): string | undefined +} diff --git a/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyMutatorInterface.ts b/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyMutatorInterface.ts new file mode 100644 index 000000000..49816c5cd --- /dev/null +++ b/packages/models/src/Domain/Syncable/ItemsKey/ItemsKeyMutatorInterface.ts @@ -0,0 +1,5 @@ +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +export interface ItemsKeyMutatorInterface extends DecryptedItemMutator { + set isDefault(isDefault: boolean) +} diff --git a/packages/models/src/Domain/Syncable/Note/Note.spec.ts b/packages/models/src/Domain/Syncable/Note/Note.spec.ts new file mode 100644 index 000000000..c5704f1e2 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Note/Note.spec.ts @@ -0,0 +1,42 @@ +import { createNote } from './../../Utilities/Test/SpecUtils' + +describe('SNNote Tests', () => { + it('should safely type required fields of Note when creating from PayloadContent', () => { + const note = createNote({ + title: 'Expected string', + text: ['unexpected array'] as never, + preview_plain: 'Expected preview', + preview_html: {} as never, + hidePreview: 'string' as never, + }) + + expect([ + typeof note.title, + typeof note.text, + typeof note.preview_html, + typeof note.preview_plain, + typeof note.hidePreview, + ]).toStrictEqual(['string', 'string', 'string', 'string', 'boolean']) + }) + + it('should preserve falsy values when casting from PayloadContent', () => { + const note = createNote({ + preview_plain: null as never, + preview_html: undefined, + }) + + expect(note.preview_plain).toBeFalsy() + expect(note.preview_html).toBeFalsy() + }) + + it('should set mobilePrefersPlainEditor when given a valid choice', () => { + const selected = createNote({ + mobilePrefersPlainEditor: true, + }) + + const unselected = createNote() + + expect(selected.mobilePrefersPlainEditor).toBeTruthy() + expect(unselected.mobilePrefersPlainEditor).toBe(undefined) + }) +}) diff --git a/packages/models/src/Domain/Syncable/Note/Note.ts b/packages/models/src/Domain/Syncable/Note/Note.ts new file mode 100644 index 000000000..21a963bea --- /dev/null +++ b/packages/models/src/Domain/Syncable/Note/Note.ts @@ -0,0 +1,34 @@ +import { ContentType } from '@standardnotes/common' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { NoteContent, NoteContentSpecialized } from './NoteContent' + +export const isNote = (x: ItemInterface): x is SNNote => x.content_type === ContentType.Note + +export class SNNote extends DecryptedItem implements NoteContentSpecialized { + public readonly title: string + public readonly text: string + public readonly mobilePrefersPlainEditor?: boolean + public readonly hidePreview: boolean = false + public readonly preview_plain: string + public readonly preview_html: string + public readonly prefersPlainEditor: boolean + public readonly spellcheck?: boolean + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.title = String(this.payload.content.title || '') + this.text = String(this.payload.content.text || '') + this.preview_plain = String(this.payload.content.preview_plain || '') + this.preview_html = String(this.payload.content.preview_html || '') + this.hidePreview = Boolean(this.payload.content.hidePreview) + this.spellcheck = this.payload.content.spellcheck + + this.prefersPlainEditor = this.getAppDomainValueWithDefault(AppDataField.PrefersPlainEditor, false) + + this.mobilePrefersPlainEditor = this.payload.content.mobilePrefersPlainEditor + } +} diff --git a/packages/models/src/Domain/Syncable/Note/NoteContent.ts b/packages/models/src/Domain/Syncable/Note/NoteContent.ts new file mode 100644 index 000000000..6e4202c55 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Note/NoteContent.ts @@ -0,0 +1,13 @@ +import { ItemContent } from '../../Abstract/Content/ItemContent' + +export interface NoteContentSpecialized { + title: string + text: string + mobilePrefersPlainEditor?: boolean + hidePreview?: boolean + preview_plain?: string + preview_html?: string + spellcheck?: boolean +} + +export type NoteContent = NoteContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts new file mode 100644 index 000000000..5337773e4 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts @@ -0,0 +1,41 @@ +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { NoteContent } from './NoteContent' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +export class NoteMutator extends DecryptedItemMutator { + set title(title: string) { + this.mutableContent.title = title + } + + set text(text: string) { + this.mutableContent.text = text + } + + set hidePreview(hidePreview: boolean) { + this.mutableContent.hidePreview = hidePreview + } + + set preview_plain(preview_plain: string) { + this.mutableContent.preview_plain = preview_plain + } + + set preview_html(preview_html: string | undefined) { + this.mutableContent.preview_html = preview_html + } + + set prefersPlainEditor(prefersPlainEditor: boolean) { + this.setAppDataItem(AppDataField.PrefersPlainEditor, prefersPlainEditor) + } + + set spellcheck(spellcheck: boolean) { + this.mutableContent.spellcheck = spellcheck + } + + toggleSpellcheck(): void { + if (this.mutableContent.spellcheck == undefined) { + this.mutableContent.spellcheck = false + } else { + this.mutableContent.spellcheck = !this.mutableContent.spellcheck + } + } +} diff --git a/packages/models/src/Domain/Syncable/Note/index.ts b/packages/models/src/Domain/Syncable/Note/index.ts new file mode 100644 index 000000000..fd1a49467 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Note/index.ts @@ -0,0 +1,3 @@ +export * from './Note' +export * from './NoteMutator' +export * from './NoteContent' diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartView.ts b/packages/models/src/Domain/Syncable/SmartView/SmartView.ts new file mode 100644 index 000000000..21af071a6 --- /dev/null +++ b/packages/models/src/Domain/Syncable/SmartView/SmartView.ts @@ -0,0 +1,44 @@ +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { PredicateInterface, PredicateJsonForm } from '../../Runtime/Predicate/Interface' +import { predicateFromJson } from '../../Runtime/Predicate/Generators' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' + +export const SMART_TAG_DSL_PREFIX = '![' + +export enum SystemViewId { + AllNotes = 'all-notes', + Files = 'files', + ArchivedNotes = 'archived-notes', + TrashedNotes = 'trashed-notes', + UntaggedNotes = 'untagged-notes', +} + +export interface SmartViewContent extends ItemContent { + title: string + predicate: PredicateJsonForm +} + +export function isSystemView(view: SmartView): boolean { + return Object.values(SystemViewId).includes(view.uuid as SystemViewId) +} + +/** + * A tag that defines a predicate that consumers can use + * to retrieve a dynamic list of items. + */ +export class SmartView extends DecryptedItem { + public readonly predicate!: PredicateInterface + public readonly title: string + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.title = String(this.content.title || '') + + try { + this.predicate = this.content.predicate && predicateFromJson(this.content.predicate) + } catch (error) { + console.error(error) + } + } +} diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts new file mode 100644 index 000000000..aa3b00af6 --- /dev/null +++ b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts @@ -0,0 +1,179 @@ +import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload' +import { SNNote } from '../Note/Note' +import { SmartViewContent, SmartView, SystemViewId } from './SmartView' +import { ItemWithTags } from '../../Runtime/Display/Search/ItemWithTags' +import { ContentType } from '@standardnotes/common' +import { FillItemContent } from '../../Abstract/Content/ItemContent' +import { Predicate } from '../../Runtime/Predicate/Predicate' +import { CompoundPredicate } from '../../Runtime/Predicate/CompoundPredicate' +import { PayloadTimestampDefaults } from '../../Abstract/Payload' +import { FilterDisplayOptions } from '../../Runtime/Display' +import { FileItem } from '../File' + +export function BuildSmartViews( + options: FilterDisplayOptions, + { supportsFileNavigation = false }: { supportsFileNavigation: boolean }, +): SmartView[] { + const notes = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.AllNotes, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Notes', + predicate: allNotesPredicate(options).toJson(), + }), + }), + ) + + const files = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.Files, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Files', + predicate: filesPredicate(options).toJson(), + }), + }), + ) + + const archived = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.ArchivedNotes, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Archived', + predicate: archivedNotesPredicate(options).toJson(), + }), + }), + ) + + const trash = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.TrashedNotes, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Trash', + predicate: trashedNotesPredicate(options).toJson(), + }), + }), + ) + + const untagged = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.UntaggedNotes, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Untagged', + predicate: untaggedNotesPredicate(options).toJson(), + }), + }), + ) + + if (supportsFileNavigation) { + return [notes, files, archived, trash, untagged] + } else { + return [notes, archived, trash, untagged] + } +} + +function allNotesPredicate(options: FilterDisplayOptions) { + const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.Note)] + + if (options.includeTrashed === false) { + subPredicates.push(new Predicate('trashed', '=', false)) + } + if (options.includeArchived === false) { + subPredicates.push(new Predicate('archived', '=', false)) + } + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + const predicate = new CompoundPredicate('and', subPredicates) + + return predicate +} + +function filesPredicate(options: FilterDisplayOptions) { + const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.File)] + + if (options.includeTrashed === false) { + subPredicates.push(new Predicate('trashed', '=', false)) + } + if (options.includeArchived === false) { + subPredicates.push(new Predicate('archived', '=', false)) + } + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + const predicate = new CompoundPredicate('and', subPredicates) + + return predicate +} + +function archivedNotesPredicate(options: FilterDisplayOptions) { + const subPredicates: Predicate[] = [ + new Predicate('archived', '=', true), + new Predicate('content_type', '=', ContentType.Note), + ] + if (options.includeTrashed === false) { + subPredicates.push(new Predicate('trashed', '=', false)) + } + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + const predicate = new CompoundPredicate('and', subPredicates) + + return predicate +} + +function trashedNotesPredicate(options: FilterDisplayOptions) { + const subPredicates: Predicate[] = [ + new Predicate('trashed', '=', true), + new Predicate('content_type', '=', ContentType.Note), + ] + if (options.includeArchived === false) { + subPredicates.push(new Predicate('archived', '=', false)) + } + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + const predicate = new CompoundPredicate('and', subPredicates) + + return predicate +} + +function untaggedNotesPredicate(options: FilterDisplayOptions) { + const subPredicates = [ + new Predicate('content_type', '=', ContentType.Note), + new Predicate('tagsCount', '=', 0), + ] + if (options.includeArchived === false) { + subPredicates.push(new Predicate('archived', '=', false)) + } + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + const predicate = new CompoundPredicate('and', subPredicates) + + return predicate +} diff --git a/packages/models/src/Domain/Syncable/SmartView/index.ts b/packages/models/src/Domain/Syncable/SmartView/index.ts new file mode 100644 index 000000000..8692368bf --- /dev/null +++ b/packages/models/src/Domain/Syncable/SmartView/index.ts @@ -0,0 +1,2 @@ +export * from './SmartView' +export * from './SmartViewBuilder' diff --git a/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts b/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts new file mode 100644 index 000000000..ed5ab63b8 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts @@ -0,0 +1,40 @@ +import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource' +import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload' +import { SNTag, TagContent } from './Tag' +import { ContentType } from '@standardnotes/common' +import { FillItemContent } from '../../Abstract/Content/ItemContent' +import { ContentReference } from '../../Abstract/Reference/ContentReference' +import { PayloadTimestampDefaults } from '../../Abstract/Payload' + +const randUuid = () => String(Math.random()) + +const create = (title: string, references: ContentReference[] = []): SNTag => { + const tag = new SNTag( + new DecryptedPayload( + { + uuid: randUuid(), + content_type: ContentType.Tag, + content: FillItemContent({ + title, + references, + } as TagContent), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) + + return tag +} + +describe('SNTag Tests', () => { + it('should count notes in the basic case', () => { + const tag = create('helloworld', [ + { uuid: randUuid(), content_type: ContentType.Note }, + { uuid: randUuid(), content_type: ContentType.Note }, + { uuid: randUuid(), content_type: ContentType.Tag }, + ]) + + expect(tag.noteCount).toEqual(2) + }) +}) diff --git a/packages/models/src/Domain/Syncable/Tag/Tag.ts b/packages/models/src/Domain/Syncable/Tag/Tag.ts new file mode 100644 index 000000000..062c0786e --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/Tag.ts @@ -0,0 +1,56 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { ContentReference } from '../../Abstract/Reference/ContentReference' +import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' + +export const TagFolderDelimitter = '.' + +interface TagInterface { + title: string + expanded: boolean +} + +export type TagContent = TagInterface & ItemContent + +export const isTag = (x: ItemInterface): x is SNTag => x.content_type === ContentType.Tag + +export class SNTag extends DecryptedItem implements TagInterface { + public readonly title: string + + /** Whether to render child tags in view hierarchy. Opposite of collapsed. */ + public readonly expanded: boolean + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.title = this.payload.content.title || '' + this.expanded = this.payload.content.expanded != undefined ? this.payload.content.expanded : true + } + + get noteReferences(): ContentReference[] { + const references = this.payload.references + return references.filter((ref) => ref.content_type === ContentType.Note) + } + + get noteCount(): number { + return this.noteReferences.length + } + + public get parentId(): Uuid | undefined { + const reference = this.references.find(isTagToParentTagReference) + return reference?.uuid + } + + public static arrayToDisplayString(tags: SNTag[]): string { + return tags + .sort((a, b) => { + return a.title > b.title ? 1 : -1 + }) + .map((tag) => { + return '#' + tag.title + }) + .join(' ') + } +} diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts new file mode 100644 index 000000000..5141f8644 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts @@ -0,0 +1,38 @@ +import { ContentType } from '@standardnotes/common' +import { ContenteReferenceType, MutationType } from '../../Abstract/Item' +import { createFile, createTag } from '../../Utilities/Test/SpecUtils' +import { SNTag } from './Tag' +import { TagMutator } from './TagMutator' + +describe('tag mutator', () => { + it('should add file to tag', () => { + const file = createFile() + + const tag = createTag() + const mutator = new TagMutator(tag, MutationType.UpdateUserTimestamps) + mutator.addFile(file) + const result = mutator.getResult() + + expect(result.content.references[0]).toEqual({ + uuid: file.uuid, + content_type: ContentType.File, + reference_type: ContenteReferenceType.TagToFile, + }) + }) + + it('should remove file from tag', () => { + const file = createFile() + + const tag = createTag() + const addMutator = new TagMutator(tag, MutationType.UpdateUserTimestamps) + addMutator.addFile(file) + const addResult = addMutator.getResult() + + const mutatedTag = new SNTag(addResult) + const removeMutator = new TagMutator(mutatedTag, MutationType.UpdateUserTimestamps) + removeMutator.removeFile(file) + const removeResult = removeMutator.getResult() + + expect(removeResult.content.references).toHaveLength(0) + }) +}) diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts new file mode 100644 index 000000000..91527163d --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts @@ -0,0 +1,70 @@ +import { ContentType } from '@standardnotes/common' +import { TagContent, SNTag } from './Tag' +import { FileItem } from '../File' +import { SNNote } from '../Note' +import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' +import { TagToParentTagReference } from '../../Abstract/Reference/TagToParentTagReference' +import { ContenteReferenceType } from '../../Abstract/Reference/ContenteReferenceType' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference' + +export class TagMutator extends DecryptedItemMutator { + set title(title: string) { + this.mutableContent.title = title + } + + set expanded(expanded: boolean) { + this.mutableContent.expanded = expanded + } + + public makeChildOf(tag: SNTag): void { + const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref)) + + const reference: TagToParentTagReference = { + reference_type: ContenteReferenceType.TagToParentTag, + content_type: ContentType.Tag, + uuid: tag.uuid, + } + + references.push(reference) + + this.mutableContent.references = references + } + + public unsetParent(): void { + this.mutableContent.references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref)) + } + + public addFile(file: FileItem): void { + if (this.immutableItem.isReferencingItem(file)) { + return + } + + const reference: TagToFileReference = { + reference_type: ContenteReferenceType.TagToFile, + content_type: ContentType.File, + uuid: file.uuid, + } + + this.mutableContent.references.push(reference) + } + + public removeFile(file: FileItem): void { + this.mutableContent.references = this.mutableContent.references.filter((r) => r.uuid !== file.uuid) + } + + public addNote(note: SNNote): void { + if (this.immutableItem.isReferencingItem(note)) { + return + } + + this.mutableContent.references.push({ + uuid: note.uuid, + content_type: note.content_type, + }) + } + + public removeNote(note: SNNote): void { + this.mutableContent.references = this.mutableContent.references.filter((r) => r.uuid !== note.uuid) + } +} diff --git a/packages/models/src/Domain/Syncable/Tag/index.ts b/packages/models/src/Domain/Syncable/Tag/index.ts new file mode 100644 index 000000000..339182ba6 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/index.ts @@ -0,0 +1,2 @@ +export * from './Tag' +export * from './TagMutator' diff --git a/packages/models/src/Domain/Syncable/Theme/Theme.ts b/packages/models/src/Domain/Syncable/Theme/Theme.ts new file mode 100644 index 000000000..b288d4440 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Theme/Theme.ts @@ -0,0 +1,48 @@ +import { ComponentArea } from '@standardnotes/features' +import { SNComponent } from '../Component/Component' +import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy' +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { HistoryEntryInterface } from '../../Runtime/History' +import { DecryptedItemInterface, ItemInterface } from '../../Abstract/Item' +import { ContentType } from '@standardnotes/common' +import { useBoolean } from '@standardnotes/utils' + +export const isTheme = (x: ItemInterface): x is SNTheme => x.content_type === ContentType.Theme + +export class SNTheme extends SNComponent { + public override area: ComponentArea = ComponentArea.Themes + + isLayerable(): boolean { + return useBoolean(this.package_info && this.package_info.layerable, false) + } + + /** Do not duplicate under most circumstances. Always keep original */ + override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } + + getMobileRules() { + return ( + this.getAppDomainValue(AppDataField.MobileRules) || { + constants: {}, + rules: {}, + } + ) + } + + /** Same as getMobileRules but without default value. */ + hasMobileRules() { + return this.getAppDomainValue(AppDataField.MobileRules) + } + + getNotAvailOnMobile() { + return this.getAppDomainValue(AppDataField.NotAvailableOnMobile) + } + + isMobileActive() { + return this.getAppDomainValue(AppDataField.MobileActive) + } +} diff --git a/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts b/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts new file mode 100644 index 000000000..d102c8f11 --- /dev/null +++ b/packages/models/src/Domain/Syncable/Theme/ThemeMutator.ts @@ -0,0 +1,25 @@ +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' +import { ComponentContent } from '../Component/ComponentContent' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +export class ThemeMutator extends DecryptedItemMutator { + setMobileRules(rules: unknown) { + this.setAppDataItem(AppDataField.MobileRules, rules) + } + + setNotAvailOnMobile(notAvailable: boolean) { + this.setAppDataItem(AppDataField.NotAvailableOnMobile, notAvailable) + } + + set local_url(local_url: string) { + this.mutableContent.local_url = local_url + } + + /** + * We must not use .active because if you set that to true, it will also + * activate that theme on desktop/web + */ + setMobileActive(active: boolean) { + this.setAppDataItem(AppDataField.MobileActive, active) + } +} diff --git a/packages/models/src/Domain/Syncable/Theme/index.ts b/packages/models/src/Domain/Syncable/Theme/index.ts new file mode 100644 index 000000000..97b0c8f3e --- /dev/null +++ b/packages/models/src/Domain/Syncable/Theme/index.ts @@ -0,0 +1,2 @@ +export * from './Theme' +export * from './ThemeMutator' diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts new file mode 100644 index 000000000..c52a1f29e --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -0,0 +1,68 @@ +import { CollectionSortProperty } from '../../Runtime/Collection/CollectionSort' +import { FeatureIdentifier } from '@standardnotes/features' + +export enum PrefKey { + TagsPanelWidth = 'tagsPanelWidth', + NotesPanelWidth = 'notesPanelWidth', + EditorWidth = 'editorWidth', + EditorLeft = 'editorLeft', + EditorMonospaceEnabled = 'monospaceFont', + EditorSpellcheck = 'spellcheck', + EditorResizersEnabled = 'marginResizersEnabled', + SortNotesBy = 'sortBy', + SortNotesReverse = 'sortReverse', + NotesShowArchived = 'showArchived', + NotesShowTrashed = 'showTrashed', + NotesHideProtected = 'hideProtected', + NotesHidePinned = 'hidePinned', + NotesHideNotePreview = 'hideNotePreview', + NotesHideDate = 'hideDate', + NotesHideTags = 'hideTags', + NotesHideEditorIcon = 'hideEditorIcon', + UseSystemColorScheme = 'useSystemColorScheme', + AutoLightThemeIdentifier = 'autoLightThemeIdentifier', + AutoDarkThemeIdentifier = 'autoDarkThemeIdentifier', + NoteAddToParentFolders = 'noteAddToParentFolders', + MobileSortNotesBy = 'mobileSortBy', + MobileSortNotesReverse = 'mobileSortReverse', + MobileNotesHideNotePreview = 'mobileHideNotePreview', + MobileNotesHideDate = 'mobileHideDate', + MobileNotesHideTags = 'mobileHideTags', + MobileLastExportDate = 'mobileLastExportDate', + MobileDoNotShowAgainUnsupportedEditors = 'mobileDoNotShowAgainUnsupportedEditors', + MobileSelectedTagUuid = 'mobileSelectedTagUuid', + MobileNotesHideEditorIcon = 'mobileHideEditorIcon', +} + +export type PrefValue = { + [PrefKey.TagsPanelWidth]: number + [PrefKey.NotesPanelWidth]: number + [PrefKey.EditorWidth]: number | null + [PrefKey.EditorLeft]: number | null + [PrefKey.EditorMonospaceEnabled]: boolean + [PrefKey.EditorSpellcheck]: boolean + [PrefKey.EditorResizersEnabled]: boolean + [PrefKey.SortNotesBy]: CollectionSortProperty + [PrefKey.SortNotesReverse]: boolean + [PrefKey.NotesShowArchived]: boolean + [PrefKey.NotesShowTrashed]: boolean + [PrefKey.NotesHidePinned]: boolean + [PrefKey.NotesHideProtected]: boolean + [PrefKey.NotesHideNotePreview]: boolean + [PrefKey.NotesHideDate]: boolean + [PrefKey.NotesHideTags]: boolean + [PrefKey.NotesHideEditorIcon]: boolean + [PrefKey.UseSystemColorScheme]: boolean + [PrefKey.AutoLightThemeIdentifier]: FeatureIdentifier | 'Default' + [PrefKey.AutoDarkThemeIdentifier]: FeatureIdentifier | 'Default' + [PrefKey.NoteAddToParentFolders]: boolean + [PrefKey.MobileSortNotesBy]: CollectionSortProperty + [PrefKey.MobileSortNotesReverse]: boolean + [PrefKey.MobileNotesHideNotePreview]: boolean + [PrefKey.MobileNotesHideDate]: boolean + [PrefKey.MobileNotesHideTags]: boolean + [PrefKey.MobileLastExportDate]: Date | undefined + [PrefKey.MobileDoNotShowAgainUnsupportedEditors]: boolean + [PrefKey.MobileSelectedTagUuid]: string | undefined + [PrefKey.MobileNotesHideEditorIcon]: boolean +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/UserPrefs.ts b/packages/models/src/Domain/Syncable/UserPrefs/UserPrefs.ts new file mode 100644 index 000000000..75aeceff2 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/UserPrefs.ts @@ -0,0 +1,20 @@ +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { ContentType } from '@standardnotes/common' +import { Predicate } from '../../Runtime/Predicate/Predicate' +import { PrefKey, PrefValue } from './PrefKey' + +export class SNUserPrefs extends DecryptedItem { + static singletonPredicate = new Predicate('content_type', '=', ContentType.UserPrefs) + + override get isSingleton(): true { + return true + } + + override singletonPredicate(): Predicate { + return SNUserPrefs.singletonPredicate + } + + getPref(key: K): PrefValue[K] | undefined { + return this.getAppDomainValue(key) + } +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/UserPrefsMutator.ts b/packages/models/src/Domain/Syncable/UserPrefs/UserPrefsMutator.ts new file mode 100644 index 000000000..d8ad7c5f8 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/UserPrefsMutator.ts @@ -0,0 +1,8 @@ +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { PrefKey, PrefValue } from './PrefKey' + +export class UserPrefsMutator extends DecryptedItemMutator { + setPref(key: K, value: PrefValue[K]): void { + this.setAppDataItem(key, value) + } +} diff --git a/packages/models/src/Domain/Syncable/UserPrefs/index.ts b/packages/models/src/Domain/Syncable/UserPrefs/index.ts new file mode 100644 index 000000000..3953a3ae1 --- /dev/null +++ b/packages/models/src/Domain/Syncable/UserPrefs/index.ts @@ -0,0 +1,3 @@ +export * from './UserPrefs' +export * from './UserPrefsMutator' +export * from './PrefKey' diff --git a/packages/models/src/Domain/Utilities/Item/FindItem.ts b/packages/models/src/Domain/Utilities/Item/FindItem.ts new file mode 100644 index 000000000..13092e2ca --- /dev/null +++ b/packages/models/src/Domain/Utilities/Item/FindItem.ts @@ -0,0 +1,10 @@ +import { Uuid } from '@standardnotes/common' +import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' + +export function FindItem(items: I[], uuid: Uuid): I | undefined { + return items.find((item) => item.uuid === uuid) +} + +export function SureFindItem(items: I[], uuid: Uuid): I { + return FindItem(items, uuid) as I +} diff --git a/packages/models/src/Domain/Utilities/Item/ItemContentsDiffer.ts b/packages/models/src/Domain/Utilities/Item/ItemContentsDiffer.ts new file mode 100644 index 000000000..c99631c43 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Item/ItemContentsDiffer.ts @@ -0,0 +1,16 @@ +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { ItemContentsEqual } from './ItemContentsEqual' + +export function ItemContentsDiffer( + item1: DecryptedItemInterface, + item2: DecryptedItemInterface, + excludeContentKeys: (keyof ItemContent)[] = [], +) { + return !ItemContentsEqual( + item1.content as ItemContent, + item2.content as ItemContent, + [...item1.contentKeysToIgnoreWhenCheckingEquality(), ...excludeContentKeys], + item1.appDataContentKeysToIgnoreWhenCheckingEquality(), + ) +} diff --git a/packages/models/src/Domain/Utilities/Item/ItemContentsEqual.ts b/packages/models/src/Domain/Utilities/Item/ItemContentsEqual.ts new file mode 100644 index 000000000..e138b8ac3 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Item/ItemContentsEqual.ts @@ -0,0 +1,47 @@ +import { omitInPlace, sortedCopy } from '@standardnotes/utils' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DefaultAppDomain } from '../../Abstract/Item/Types/DefaultAppDomain' +import { AppDataField } from '../../Abstract/Item/Types/AppDataField' + +export function ItemContentsEqual( + leftContent: C, + rightContent: C, + keysToIgnore: (keyof C)[], + appDataKeysToIgnore: AppDataField[], +) { + /* Create copies of objects before running omit as not to modify source values directly. */ + const leftContentCopy: Partial = sortedCopy(leftContent) + if (leftContentCopy.appData) { + const domainData = leftContentCopy.appData[DefaultAppDomain] + omitInPlace(domainData, appDataKeysToIgnore) + /** + * We don't want to disqualify comparison if one object contains an empty domain object + * and the other doesn't contain a domain object. This can happen if you create an item + * without setting dirty, which means it won't be initialized with a client_updated_at + */ + if (domainData) { + if (Object.keys(domainData).length === 0) { + delete leftContentCopy.appData + } + } else { + delete leftContentCopy.appData + } + } + omitInPlace>(leftContentCopy, keysToIgnore) + + const rightContentCopy: Partial = sortedCopy(rightContent) + if (rightContentCopy.appData) { + const domainData = rightContentCopy.appData[DefaultAppDomain] + omitInPlace(domainData, appDataKeysToIgnore) + if (domainData) { + if (Object.keys(domainData).length === 0) { + delete rightContentCopy.appData + } + } else { + delete rightContentCopy.appData + } + } + omitInPlace>(rightContentCopy, keysToIgnore) + + return JSON.stringify(leftContentCopy) === JSON.stringify(rightContentCopy) +} diff --git a/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts b/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts new file mode 100644 index 000000000..5e5793c02 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts @@ -0,0 +1,113 @@ +import { ContentType } from '@standardnotes/common' +import { EncryptedItem } from '../../Abstract/Item/Implementations/EncryptedItem' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { FileItem } from '../../Syncable/File/File' +import { SNFeatureRepo } from '../../Syncable/FeatureRepo/FeatureRepo' +import { SNActionsExtension } from '../../Syncable/ActionsExtension/ActionsExtension' +import { SNComponent } from '../../Syncable/Component/Component' +import { SNEditor } from '../../Syncable/Editor/Editor' +import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' +import { SNNote } from '../../Syncable/Note/Note' +import { SmartView } from '../../Syncable/SmartView/SmartView' +import { SNTag } from '../../Syncable/Tag/Tag' +import { SNTheme } from '../../Syncable/Theme/Theme' +import { SNUserPrefs } from '../../Syncable/UserPrefs/UserPrefs' +import { FileMutator } from '../../Syncable/File/FileMutator' +import { MutationType } from '../../Abstract/Item/Types/MutationType' +import { ThemeMutator } from '../../Syncable/Theme/ThemeMutator' +import { UserPrefsMutator } from '../../Syncable/UserPrefs/UserPrefsMutator' +import { ActionsExtensionMutator } from '../../Syncable/ActionsExtension/ActionsExtensionMutator' +import { ComponentMutator } from '../../Syncable/Component/ComponentMutator' +import { TagMutator } from '../../Syncable/Tag/TagMutator' +import { NoteMutator } from '../../Syncable/Note/NoteMutator' +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { + DeletedPayloadInterface, + EncryptedPayloadInterface, + isDecryptedPayload, + isDeletedPayload, + isEncryptedPayload, +} from '../../Abstract/Payload' +import { DeletedItem } from '../../Abstract/Item/Implementations/DeletedItem' +import { EncryptedItemInterface } from '../../Abstract/Item/Interfaces/EncryptedItem' +import { DeletedItemInterface } from '../../Abstract/Item/Interfaces/DeletedItem' + +type ItemClass = new (payload: DecryptedPayloadInterface) => DecryptedItem + +type MutatorClass = new ( + item: DecryptedItemInterface, + type: MutationType, +) => DecryptedItemMutator + +type MappingEntry = { + itemClass: ItemClass + mutatorClass?: MutatorClass +} + +const ContentTypeClassMapping: Partial> = { + [ContentType.ActionsExtension]: { + itemClass: SNActionsExtension, + mutatorClass: ActionsExtensionMutator, + }, + [ContentType.Component]: { itemClass: SNComponent, mutatorClass: ComponentMutator }, + [ContentType.Editor]: { itemClass: SNEditor }, + [ContentType.ExtensionRepo]: { itemClass: SNFeatureRepo }, + [ContentType.File]: { itemClass: FileItem, mutatorClass: FileMutator }, + [ContentType.Note]: { itemClass: SNNote, mutatorClass: NoteMutator }, + [ContentType.SmartView]: { itemClass: SmartView, mutatorClass: TagMutator }, + [ContentType.Tag]: { itemClass: SNTag, mutatorClass: TagMutator }, + [ContentType.Theme]: { itemClass: SNTheme, mutatorClass: ThemeMutator }, + [ContentType.UserPrefs]: { itemClass: SNUserPrefs, mutatorClass: UserPrefsMutator }, +} as unknown as Partial> + +export function CreateDecryptedMutatorForItem< + I extends DecryptedItemInterface, + M extends DecryptedItemMutator = DecryptedItemMutator, +>(item: I, type: MutationType): M { + const lookupValue = ContentTypeClassMapping[item.content_type]?.mutatorClass + if (lookupValue) { + return new lookupValue(item, type) as M + } else { + return new DecryptedItemMutator(item, type) as M + } +} + +export function RegisterItemClass< + C extends ItemContent = ItemContent, + M extends DecryptedItemMutator = DecryptedItemMutator, +>(contentType: ContentType, itemClass: ItemClass, mutatorClass: M) { + const entry: MappingEntry = { + itemClass: itemClass, + mutatorClass: mutatorClass as unknown as MutatorClass, + } + ContentTypeClassMapping[contentType] = entry as unknown as MappingEntry +} + +export function CreateDecryptedItemFromPayload< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, +>(payload: DecryptedPayloadInterface): T { + const lookupClass = ContentTypeClassMapping[payload.content_type] + const itemClass = lookupClass ? lookupClass.itemClass : DecryptedItem + const item = new itemClass(payload) + return item as unknown as T +} + +export function CreateItemFromPayload< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, +>( + payload: DecryptedPayloadInterface | EncryptedPayloadInterface | DeletedPayloadInterface, +): EncryptedItemInterface | DeletedItemInterface | T { + if (isDecryptedPayload(payload)) { + return CreateDecryptedItemFromPayload(payload) + } else if (isEncryptedPayload(payload)) { + return new EncryptedItem(payload) + } else if (isDeletedPayload(payload)) { + return new DeletedItem(payload) + } else { + throw Error('Unhandled case in CreateItemFromPayload') + } +} diff --git a/packages/models/src/Domain/Utilities/Payload/AffectorFunction.ts b/packages/models/src/Domain/Utilities/Payload/AffectorFunction.ts new file mode 100644 index 000000000..a75e37c65 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/AffectorFunction.ts @@ -0,0 +1,55 @@ +import { DecryptedPayloadInterface } from './../../Abstract/Payload/Interfaces/DecryptedPayload' +import { ComponentContent } from '../../Syncable/Component/ComponentContent' +import { ComponentArea } from '@standardnotes/features' +import { ContentType } from '@standardnotes/common' +import { ComponentMutator, SNComponent } from '../../Syncable/Component' +import { CreateDecryptedItemFromPayload } from '../Item/ItemGenerator' +import { ImmutablePayloadCollection } from '../../Runtime/Collection/Payload/ImmutablePayloadCollection' +import { MutationType } from '../../Abstract/Item/Types/MutationType' +import { FullyFormedPayloadInterface } from '../../Abstract/Payload/Interfaces/UnionTypes' +import { isDecryptedPayload } from '../../Abstract/Payload' +import { SyncResolvedPayload } from '../../Runtime/Deltas/Utilities/SyncResolvedPayload' + +export type AffectorFunction = ( + basePayload: FullyFormedPayloadInterface, + duplicatePayload: FullyFormedPayloadInterface, + baseCollection: ImmutablePayloadCollection, +) => SyncResolvedPayload[] + +const NoteDuplicationAffectedPayloads: AffectorFunction = ( + basePayload: FullyFormedPayloadInterface, + duplicatePayload: FullyFormedPayloadInterface, + baseCollection: ImmutablePayloadCollection, +) => { + /** If note has editor, maintain editor relationship in duplicate note */ + const components = baseCollection + .all(ContentType.Component) + .filter(isDecryptedPayload) + .map((payload) => { + return CreateDecryptedItemFromPayload( + payload as DecryptedPayloadInterface, + ) + }) + + const editor = components + .filter((c) => c.area === ComponentArea.Editor) + .find((e) => { + return e.isExplicitlyEnabledForItem(basePayload.uuid) + }) + + if (!editor) { + return [] + } + + /** Modify the editor to include new note */ + const mutator = new ComponentMutator(editor, MutationType.NoUpdateUserTimestamps) + mutator.associateWithItem(duplicatePayload.uuid) + + const result = mutator.getResult() as SyncResolvedPayload + + return [result] +} + +export const AffectorMapping = { + [ContentType.Note]: NoteDuplicationAffectedPayloads, +} as Partial> diff --git a/packages/models/src/Domain/Utilities/Payload/ConditionalPayloadType.ts b/packages/models/src/Domain/Utilities/Payload/ConditionalPayloadType.ts new file mode 100644 index 000000000..caebd9606 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/ConditionalPayloadType.ts @@ -0,0 +1,20 @@ +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { DeletedPayloadInterface } from '../../Abstract/Payload/Interfaces/DeletedPayload' +import { EncryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/EncryptedPayload' +import { + DecryptedTransferPayload, + DeletedTransferPayload, + EncryptedTransferPayload, +} from '../../Abstract/TransferPayload' + +export type ConditionalPayloadType = T extends DecryptedTransferPayload + ? DecryptedPayloadInterface + : T extends EncryptedTransferPayload + ? EncryptedPayloadInterface + : DeletedPayloadInterface + +export type ConditionalTransferPayloadType

= P extends DecryptedPayloadInterface + ? DecryptedTransferPayload + : P extends EncryptedPayloadInterface + ? EncryptedTransferPayload + : DeletedTransferPayload diff --git a/packages/models/src/Domain/Utilities/Payload/CopyPayloadWithContentOverride.ts b/packages/models/src/Domain/Utilities/Payload/CopyPayloadWithContentOverride.ts new file mode 100644 index 000000000..f0533f84e --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/CopyPayloadWithContentOverride.ts @@ -0,0 +1,19 @@ +import { CreatePayload } from './CreatePayload' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedTransferPayload } from '../../Abstract/TransferPayload' + +export function CopyPayloadWithContentOverride( + payload: DecryptedPayloadInterface, + contentOverride: Partial, +): DecryptedPayloadInterface { + const params: DecryptedTransferPayload = { + ...payload.ejected(), + content: { + ...payload.content, + ...contentOverride, + }, + } + const result = CreatePayload(params, payload.source) + return result +} diff --git a/packages/models/src/Domain/Utilities/Payload/CreatePayload.ts b/packages/models/src/Domain/Utilities/Payload/CreatePayload.ts new file mode 100644 index 000000000..98329eb45 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/CreatePayload.ts @@ -0,0 +1,26 @@ +import { EncryptedPayload } from '../../Abstract/Payload/Implementations/EncryptedPayload' +import { DeletedPayload } from '../../Abstract/Payload/Implementations/DeletedPayload' +import { DecryptedPayload } from '../../Abstract/Payload/Implementations/DecryptedPayload' +import { + FullyFormedTransferPayload, + isDecryptedTransferPayload, + isDeletedTransferPayload, + isEncryptedTransferPayload, +} from '../../Abstract/TransferPayload' +import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource' +import { ConditionalPayloadType } from './ConditionalPayloadType' + +export function CreatePayload( + from: T, + source: PayloadSource, +): ConditionalPayloadType { + if (isDecryptedTransferPayload(from)) { + return new DecryptedPayload(from, source) as unknown as ConditionalPayloadType + } else if (isEncryptedTransferPayload(from)) { + return new EncryptedPayload(from, source) as unknown as ConditionalPayloadType + } else if (isDeletedTransferPayload(from)) { + return new DeletedPayload(from, source) as unknown as ConditionalPayloadType + } else { + throw Error('Unhandled case in CreatePayload') + } +} diff --git a/packages/models/src/Domain/Utilities/Payload/FindPayload.ts b/packages/models/src/Domain/Utilities/Payload/FindPayload.ts new file mode 100644 index 000000000..edce5103f --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/FindPayload.ts @@ -0,0 +1,10 @@ +import { Uuid } from '@standardnotes/common' +import { PayloadInterface } from '../../Abstract/Payload/Interfaces/PayloadInterface' + +export function FindPayload

(payloads: P[], uuid: Uuid): P | undefined { + return payloads.find((payload) => payload.uuid === uuid) +} + +export function SureFindPayload

(payloads: P[], uuid: Uuid): P { + return FindPayload(payloads, uuid) as P +} diff --git a/packages/models/src/Domain/Utilities/Payload/PayloadContentsEqual.ts b/packages/models/src/Domain/Utilities/Payload/PayloadContentsEqual.ts new file mode 100644 index 000000000..f0b28f765 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/PayloadContentsEqual.ts @@ -0,0 +1,15 @@ +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { CreateDecryptedItemFromPayload } from '../Item/ItemGenerator' + +/** + * Compares the .content fields for equality, creating new SNItem objects + * to properly handle .content intricacies. + */ +export function PayloadContentsEqual( + payloadA: DecryptedPayloadInterface, + payloadB: DecryptedPayloadInterface, +): boolean { + const itemA = CreateDecryptedItemFromPayload(payloadA) + const itemB = CreateDecryptedItemFromPayload(payloadB) + return itemA.isItemContentEqualWith(itemB) +} diff --git a/packages/models/src/Domain/Utilities/Payload/PayloadSplit.ts b/packages/models/src/Domain/Utilities/Payload/PayloadSplit.ts new file mode 100644 index 000000000..276920af6 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/PayloadSplit.ts @@ -0,0 +1,98 @@ +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { DeletedPayloadInterface } from '../../Abstract/Payload/Interfaces/DeletedPayload' +import { EncryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/EncryptedPayload' +import { isDecryptedPayload, isDeletedPayload, isEncryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { FullyFormedPayloadInterface } from '../../Abstract/Payload/Interfaces/UnionTypes' + +export interface PayloadSplit { + encrypted: EncryptedPayloadInterface[] + decrypted: DecryptedPayloadInterface[] + deleted: DeletedPayloadInterface[] +} + +export interface PayloadSplitWithDiscardables { + encrypted: EncryptedPayloadInterface[] + decrypted: DecryptedPayloadInterface[] + deleted: DeletedPayloadInterface[] + discardable: DeletedPayloadInterface[] +} + +export interface NonDecryptedPayloadSplit { + encrypted: EncryptedPayloadInterface[] + deleted: DeletedPayloadInterface[] +} + +export function CreatePayloadSplit( + payloads: FullyFormedPayloadInterface[], +): PayloadSplit { + const split: PayloadSplit = { + encrypted: [], + decrypted: [], + deleted: [], + } + + for (const payload of payloads) { + if (isDecryptedPayload(payload)) { + split.decrypted.push(payload) + } else if (isEncryptedPayload(payload)) { + split.encrypted.push(payload) + } else if (isDeletedPayload(payload)) { + split.deleted.push(payload) + } else { + throw Error('Unhandled case in CreatePayloadSplit') + } + } + + return split +} + +export function CreatePayloadSplitWithDiscardables( + payloads: FullyFormedPayloadInterface[], +): PayloadSplitWithDiscardables { + const split: PayloadSplitWithDiscardables = { + encrypted: [], + decrypted: [], + deleted: [], + discardable: [], + } + + for (const payload of payloads) { + if (isDecryptedPayload(payload)) { + split.decrypted.push(payload) + } else if (isEncryptedPayload(payload)) { + split.encrypted.push(payload) + } else if (isDeletedPayload(payload)) { + if (payload.discardable) { + split.discardable.push(payload) + } else { + split.deleted.push(payload) + } + } else { + throw Error('Unhandled case in CreatePayloadSplitWithDiscardables') + } + } + + return split +} + +export function CreateNonDecryptedPayloadSplit( + payloads: (EncryptedPayloadInterface | DeletedPayloadInterface)[], +): NonDecryptedPayloadSplit { + const split: NonDecryptedPayloadSplit = { + encrypted: [], + deleted: [], + } + + for (const payload of payloads) { + if (isEncryptedPayload(payload)) { + split.encrypted.push(payload) + } else if (isDeletedPayload(payload)) { + split.deleted.push(payload) + } else { + throw Error('Unhandled case in CreateNonDecryptedPayloadSplit') + } + } + + return split +} diff --git a/packages/models/src/Domain/Utilities/Payload/PayloadsByAlternatingUuid.ts b/packages/models/src/Domain/Utilities/Payload/PayloadsByAlternatingUuid.ts new file mode 100644 index 000000000..01442ad37 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/PayloadsByAlternatingUuid.ts @@ -0,0 +1,97 @@ +import { DeletedPayload } from '../../Abstract/Payload/Implementations/DeletedPayload' +import { ContentType } from '@standardnotes/common' +import { extendArray, UuidGenerator } from '@standardnotes/utils' +import { ImmutablePayloadCollection } from '../../Runtime/Collection/Payload/ImmutablePayloadCollection' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { isEncryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { FullyFormedPayloadInterface } from '../../Abstract/Payload/Interfaces/UnionTypes' +import { EncryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/EncryptedPayload' +import { PayloadsByUpdatingReferencingPayloadReferences } from './PayloadsByUpdatingReferencingPayloadReferences' +import { SyncResolvedPayload } from '../../Runtime/Deltas/Utilities/SyncResolvedPayload' +import { getIncrementedDirtyIndex } from '../../Runtime/DirtyCounter/DirtyCounter' + +/** + * Return the payloads that result if you alternated the uuid for the payload. + * Alternating a UUID involves instructing related items to drop old references of a uuid + * for the new one. + * @returns An array of payloads that have changed as a result of copying. + */ + +export function PayloadsByAlternatingUuid

( + payload: P, + baseCollection: ImmutablePayloadCollection, +): SyncResolvedPayload[] { + const results: SyncResolvedPayload[] = [] + /** + * We need to clone payload and give it a new uuid, + * then delete item with old uuid from db (cannot modify uuids in our IndexedDB setup) + */ + const copy = payload.copyAsSyncResolved({ + uuid: UuidGenerator.GenerateUuid(), + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + lastSyncBegan: undefined, + lastSyncEnd: new Date(), + duplicate_of: payload.uuid, + }) + + results.push(copy) + + /** + * Get the payloads that make reference to payload and remove + * payload as a relationship, instead adding the new copy. + */ + const updatedReferencing = PayloadsByUpdatingReferencingPayloadReferences( + payload, + baseCollection, + [copy], + [payload.uuid], + ) + + extendArray(results, updatedReferencing) + + if (payload.content_type === ContentType.ItemsKey) { + /** + * Update any payloads who are still encrypted and whose items_key_id point to this uuid + */ + const matchingPayloads = baseCollection + .all() + .filter((p) => isEncryptedPayload(p) && p.items_key_id === payload.uuid) as EncryptedPayloadInterface[] + + const adjustedPayloads = matchingPayloads.map((a) => + a.copyAsSyncResolved({ + items_key_id: copy.uuid, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + lastSyncEnd: new Date(), + }), + ) + + if (adjustedPayloads.length > 0) { + extendArray(results, adjustedPayloads) + } + } + + const deletedSelf = new DeletedPayload( + { + created_at: payload.created_at, + updated_at: payload.updated_at, + created_at_timestamp: payload.created_at_timestamp, + updated_at_timestamp: payload.updated_at_timestamp, + /** + * Do not set as dirty; this item is non-syncable + * and should be immediately discarded + */ + dirty: false, + content: undefined, + uuid: payload.uuid, + content_type: payload.content_type, + deleted: true, + }, + payload.source, + ) + + results.push(deletedSelf as SyncResolvedPayload) + + return results +} diff --git a/packages/models/src/Domain/Utilities/Payload/PayloadsByDuplicating.ts b/packages/models/src/Domain/Utilities/Payload/PayloadsByDuplicating.ts new file mode 100644 index 000000000..755ff980e --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/PayloadsByDuplicating.ts @@ -0,0 +1,81 @@ +import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource' +import { extendArray, UuidGenerator } from '@standardnotes/utils' +import { ImmutablePayloadCollection } from '../../Runtime/Collection/Payload/ImmutablePayloadCollection' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { AffectorMapping } from './AffectorFunction' +import { PayloadsByUpdatingReferencingPayloadReferences } from './PayloadsByUpdatingReferencingPayloadReferences' +import { isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' +import { FullyFormedPayloadInterface } from '../../Abstract/Payload/Interfaces/UnionTypes' +import { SyncResolvedPayload } from '../../Runtime/Deltas/Utilities/SyncResolvedPayload' +import { getIncrementedDirtyIndex } from '../../Runtime/DirtyCounter/DirtyCounter' + +/** + * Copies payload and assigns it a new uuid. + * @returns An array of payloads that have changed as a result of copying. + */ +export function PayloadsByDuplicating(dto: { + payload: FullyFormedPayloadInterface + baseCollection: ImmutablePayloadCollection + isConflict?: boolean + additionalContent?: Partial + source?: PayloadSource +}): SyncResolvedPayload[] { + const { payload, baseCollection, isConflict, additionalContent, source } = dto + + const results: SyncResolvedPayload[] = [] + + const override = { + uuid: UuidGenerator.GenerateUuid(), + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + lastSyncBegan: undefined, + lastSyncEnd: new Date(), + duplicate_of: payload.uuid, + } + + let copy: SyncResolvedPayload + + if (isDecryptedPayload(payload)) { + const contentOverride: C = { + ...payload.content, + ...additionalContent, + } + + if (isConflict) { + contentOverride.conflict_of = payload.uuid + } + + copy = payload.copyAsSyncResolved({ + ...override, + content: contentOverride, + deleted: false, + }) + } else { + copy = payload.copyAsSyncResolved( + { + ...override, + }, + source || payload.source, + ) + } + + results.push(copy) + + if (isDecryptedPayload(payload) && isDecryptedPayload(copy)) { + /** + * Get the payloads that make reference to payload and add the copy. + */ + const updatedReferencing = PayloadsByUpdatingReferencingPayloadReferences(payload, baseCollection, [copy]) + extendArray(results, updatedReferencing) + } + + const affector = AffectorMapping[payload.content_type] + if (affector) { + const affected = affector(payload, copy, baseCollection) + if (affected) { + extendArray(results, affected) + } + } + + return results +} diff --git a/packages/models/src/Domain/Utilities/Payload/PayloadsByUpdatingReferencingPayloadReferences.ts b/packages/models/src/Domain/Utilities/Payload/PayloadsByUpdatingReferencingPayloadReferences.ts new file mode 100644 index 000000000..0c06d83d7 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Payload/PayloadsByUpdatingReferencingPayloadReferences.ts @@ -0,0 +1,52 @@ +import { Uuid } from '@standardnotes/common' +import { remove } from 'lodash' +import { ImmutablePayloadCollection } from '../../Runtime/Collection/Payload/ImmutablePayloadCollection' +import { ContentReference } from '../../Abstract/Reference/ContentReference' +import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' +import { FullyFormedPayloadInterface } from '../../Abstract/Payload/Interfaces/UnionTypes' +import { isDecryptedPayload } from '../../Abstract/Payload' +import { SyncResolvedPayload } from '../../Runtime/Deltas/Utilities/SyncResolvedPayload' +import { getIncrementedDirtyIndex } from '../../Runtime/DirtyCounter/DirtyCounter' + +export function PayloadsByUpdatingReferencingPayloadReferences( + payload: DecryptedPayloadInterface, + baseCollection: ImmutablePayloadCollection, + add: FullyFormedPayloadInterface[] = [], + removeIds: Uuid[] = [], +): SyncResolvedPayload[] { + const referencingPayloads = baseCollection.elementsReferencingElement(payload).filter(isDecryptedPayload) + + const results: SyncResolvedPayload[] = [] + + for (const referencingPayload of referencingPayloads) { + const references = referencingPayload.content.references.slice() + const reference = referencingPayload.getReference(payload.uuid) + + for (const addPayload of add) { + const newReference: ContentReference = { + ...reference, + uuid: addPayload.uuid, + content_type: addPayload.content_type, + } + references.push(newReference) + } + + for (const id of removeIds) { + remove(references, { uuid: id }) + } + + const result = referencingPayload.copyAsSyncResolved({ + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + lastSyncEnd: new Date(), + content: { + ...referencingPayload.content, + references, + }, + }) + + results.push(result) + } + + return results +} diff --git a/packages/models/src/Domain/Utilities/Test/SpecUtils.ts b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts new file mode 100644 index 000000000..9b7f7de66 --- /dev/null +++ b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts @@ -0,0 +1,80 @@ +import { TagContent } from './../../Syncable/Tag/Tag' +import { ContentType } from '@standardnotes/common' +import { FillItemContent, ItemContent } from '../../Abstract/Content/ItemContent' +import { DecryptedPayload, PayloadSource, PayloadTimestampDefaults } from '../../Abstract/Payload' +import { FileContent, FileItem } from '../../Syncable/File' +import { NoteContent, SNNote } from '../../Syncable/Note' +import { SNTag } from '../../Syncable/Tag' + +let currentId = 0 + +export const mockUuid = () => { + return `${currentId++}` +} + +export const createNote = (payload?: Partial): SNNote => { + return new SNNote( + new DecryptedPayload( + { + uuid: mockUuid(), + content_type: ContentType.Note, + content: FillItemContent({ ...payload }), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) +} + +export const createNoteWithContent = (content: Partial, createdAt?: Date): SNNote => { + return new SNNote( + new DecryptedPayload( + { + uuid: mockUuid(), + content_type: ContentType.Note, + content: FillItemContent(content), + ...PayloadTimestampDefaults(), + created_at: createdAt || new Date(), + }, + PayloadSource.Constructor, + ), + ) +} + +export const createTag = (title = 'photos') => { + return new SNTag( + new DecryptedPayload( + { + uuid: mockUuid(), + content_type: ContentType.Tag, + content: FillItemContent({ title }), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) +} + +export const createFile = (name = 'screenshot.png') => { + return new FileItem( + new DecryptedPayload( + { + uuid: mockUuid(), + content_type: ContentType.File, + content: FillItemContent({ name }), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) +} + +export const pinnedContent = (): Partial => { + return { + appData: { + 'org.standardnotes.sn': { + pinned: true, + }, + }, + } +} diff --git a/packages/models/src/Domain/index.ts b/packages/models/src/Domain/index.ts new file mode 100644 index 000000000..a11b7ce49 --- /dev/null +++ b/packages/models/src/Domain/index.ts @@ -0,0 +1,55 @@ +export * from './Abstract/Content/ItemContent' +export * from './Abstract/Contextual' +export * from './Abstract/Item' +export * from './Abstract/Payload' +export * from './Abstract/TransferPayload' +export * from './Local/KeyParams/RootKeyParamsInterface' +export * from './Local/RootKey/KeychainTypes' +export * from './Local/RootKey/RootKeyContent' +export * from './Local/RootKey/RootKeyInterface' +export * from './Runtime/Collection/CollectionSort' +export * from './Runtime/Collection/Item/ItemCollection' +export * from './Runtime/Collection/Item/TagNotesIndex' +export * from './Runtime/Collection/Payload/ImmutablePayloadCollection' +export * from './Runtime/Collection/Payload/PayloadCollection' +export * from './Runtime/Deltas' +export * from './Runtime/DirtyCounter/DirtyCounter' +export * from './Runtime/Display/ItemDisplayController' +export * from './Runtime/Display/Types' +export * from './Runtime/Display' +export * from './Runtime/History' +export * from './Runtime/Index/ItemDelta' +export * from './Runtime/Index/SNIndex' +export * from './Runtime/Predicate/CompoundPredicate' +export * from './Runtime/Predicate/Generators' +export * from './Runtime/Predicate/IncludesPredicate' +export * from './Runtime/Predicate/Interface' +export * from './Runtime/Predicate/Interface' +export * from './Runtime/Predicate/NotPredicate' +export * from './Runtime/Predicate/Operator' +export * from './Runtime/Predicate/Predicate' +export * from './Runtime/Predicate/Utils' +export * from './Syncable/ActionsExtension' +export * from './Syncable/Component' +export * from './Syncable/Editor' +export * from './Syncable/FeatureRepo' +export * from './Syncable/File' +export * from './Syncable/ItemsKey/ItemsKeyInterface' +export * from './Syncable/ItemsKey/ItemsKeyMutatorInterface' +export * from './Syncable/Note' +export * from './Syncable/SmartView' +export * from './Syncable/Tag' +export * from './Syncable/Theme' +export * from './Syncable/UserPrefs' +export * from './Utilities/Item/FindItem' +export * from './Utilities/Item/ItemContentsDiffer' +export * from './Utilities/Item/ItemContentsEqual' +export * from './Utilities/Item/ItemGenerator' +export * from './Utilities/Payload/AffectorFunction' +export * from './Utilities/Payload/CopyPayloadWithContentOverride' +export * from './Utilities/Payload/CreatePayload' +export * from './Utilities/Payload/FindPayload' +export * from './Utilities/Payload/PayloadContentsEqual' +export * from './Utilities/Payload/PayloadsByAlternatingUuid' +export * from './Utilities/Payload/PayloadsByDuplicating' +export * from './Utilities/Payload/PayloadSplit' diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts new file mode 100644 index 000000000..920deacdb --- /dev/null +++ b/packages/models/src/index.ts @@ -0,0 +1 @@ +export * from './Domain' diff --git a/packages/models/tsconfig.json b/packages/models/tsconfig.json new file mode 100644 index 000000000..f3dac14ef --- /dev/null +++ b/packages/models/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../node_modules/@standardnotes/config/src/tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*" + ], + "references": [], + "exclude": ["**/*.spec.ts", "dist", "node_modules"] +} diff --git a/yarn.lock b/yarn.lock index f08e58925..76ed598bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6547,7 +6547,7 @@ __metadata: dependencies: "@standardnotes/common": ^1.23.1 "@standardnotes/config": 2.4.3 - "@standardnotes/models": ^1.11.13 + "@standardnotes/models": "workspace:*" "@standardnotes/responses": ^1.6.39 "@standardnotes/services": ^1.13.23 "@standardnotes/sncrypto-common": ^1.9.0 @@ -6624,7 +6624,7 @@ __metadata: "@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 @@ -7006,27 +7006,24 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/models@npm:^1.11.12": - version: 1.11.12 - resolution: "@standardnotes/models@npm:1.11.12" +"@standardnotes/models@^1.11.12, @standardnotes/models@^1.11.13, @standardnotes/models@workspace:*, @standardnotes/models@workspace:packages/models": + version: 0.0.0-use.local + resolution: "@standardnotes/models@workspace:packages/models" dependencies: - "@standardnotes/features": ^1.46.0 - "@standardnotes/responses": ^1.6.38 - "@standardnotes/utils": ^1.6.12 - checksum: 636897db975e59989da86bcf2a6f32a4f8e24c45e41f9f9ee7c6960e47f3d04bcc9b74aadb9c4b488d98b61aca284699ed8b2a857d588493300358c62d69e580 - languageName: node - linkType: hard - -"@standardnotes/models@npm:^1.11.13": - version: 1.11.13 - resolution: "@standardnotes/models@npm:1.11.13" - dependencies: - "@standardnotes/features": ^1.47.0 + "@standardnotes/common": ^1.23.1 + "@standardnotes/features": "workspace:*" "@standardnotes/responses": ^1.6.39 "@standardnotes/utils": ^1.6.12 - checksum: 063f4382b8559f23a81db0a3aab7eca023c926f2069182f364da6dbfb0d312c300bc9dd29a1d389ba5fa1e7c04d8375c625bfc4eb073cad938511493e2976b5a - languageName: node - linkType: hard + "@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 + lodash: ^4.17.21 + reflect-metadata: ^0.1.13 + ts-jest: ^27.1.3 + languageName: unknown + linkType: soft "@standardnotes/react-native-aes@npm:^1.4.3": version: 1.4.3