core: migrate all database writes to sqlite

This commit is contained in:
Abdullah Atta
2023-10-02 09:40:10 +05:00
parent 91a272ee3d
commit a91717aca3
29 changed files with 2212 additions and 887 deletions

View File

@@ -0,0 +1,57 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { bench, describe } from "vitest";
import { databaseTest } from "../__tests__/utils";
describe("relations", async () => {
const db = await databaseTest();
// const totalNotebooks = 10;
// let parentNotebookId: string | undefined = undefined;
// for (let i = 1; i <= 100; ++i) {
// const id = await db.notebooks.add({ title: `notebook-somethign-${i}` });
// if (parentNotebookId)
// await db.relations.add(
// { id: parentNotebookId, type: "notebook" },
// { id, type: "notebook" }
// );
// parentNotebookId = id;
// for (let j = 1; j <= 100; ++j) {
// await db.relations.add(
// { type: "notebook", id },
// { id: `${j * i}-note`, type: "note" }
// );
// }
// }
//
// const id2 = await db.notebooks.add({ title: `notebook-somethign` });
console.log(await db.notebooks.totalNotes("6516a04a35a073f359e7e801"));
// console.log(
// await db.relations.from({ id: "8-note", type: "note" }, "notebook").unlink()
// );
// bench("get some relations from 10k relations", async () => {
// await db.notebooks.totalNotes("6516a04a35a073f359e7e801");
// });
});

View File

@@ -68,11 +68,12 @@ test("add invalid note", () =>
expect(db.notes.add({ hello: "world" })).rejects.toThrow(); expect(db.notes.add({ hello: "world" })).rejects.toThrow();
})); }));
test("add note", () => test.only("add note", () =>
noteTest().then(async ({ db, id }) => { noteTest().then(async ({ db, id }) => {
const note = db.notes.note(id); const note = await db.notes.note$(id);
expect(note).toBeDefined(); expect(note).toBeDefined();
expect(await note?.content()).toStrictEqual(TEST_NOTE.content.data); const content = await db.content.get(note!.contentId!);
expect(content!.data).toStrictEqual(TEST_NOTE.content.data);
})); }));
test("get note content", () => test("get note content", () =>

BIN
packages/core/nn.db Normal file

Binary file not shown.

View File

@@ -20,6 +20,7 @@
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"katex": "0.16.2", "katex": "0.16.2",
"kysely": "^0.26.3",
"linkedom": "^0.14.17", "linkedom": "^0.14.17",
"liqe": "^1.13.0", "liqe": "^1.13.0",
"mime-db": "1.52.0", "mime-db": "1.52.0",
@@ -30,6 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@notesnook/crypto": "file:../crypto", "@notesnook/crypto": "file:../crypto",
"@types/better-sqlite3": "^7.6.5",
"@types/event-source-polyfill": "^1.0.1", "@types/event-source-polyfill": "^1.0.1",
"@types/html-to-text": "^9.0.0", "@types/html-to-text": "^9.0.0",
"@types/katex": "^0.16.2", "@types/katex": "^0.16.2",
@@ -40,6 +42,7 @@
"@types/ws": "^8.5.5", "@types/ws": "^8.5.5",
"@vitest/coverage-v8": "^0.34.1", "@vitest/coverage-v8": "^0.34.1",
"abortcontroller-polyfill": "^1.7.3", "abortcontroller-polyfill": "^1.7.3",
"better-sqlite3": "^8.6.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
@@ -2173,6 +2176,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.9",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.9.tgz",
"integrity": "sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "4.3.14", "version": "4.3.14",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz",
@@ -2444,6 +2456,57 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/better-sqlite3": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.7.0.tgz",
"integrity": "sha512-99jZU4le+f3G6aIl6PmmV0cxUIWqKieHxsiF7G34CVFiE+/UabpYqkU0NJIkY/96mQKikHeBjtR27vFfs5JpEw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dev": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@@ -2457,6 +2520,30 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -2523,6 +2610,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"dev": true
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"dev": true, "dev": true,
@@ -2667,6 +2760,21 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": { "node_modules/deep-eql": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
@@ -2679,6 +2787,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"dev": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"license": "MIT", "license": "MIT",
@@ -2694,6 +2811,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/detect-libc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
"integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/diff-sequences": { "node_modules/diff-sequences": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -2773,6 +2899,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
@@ -2840,6 +2975,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/fetch-cookie": { "node_modules/fetch-cookie": {
"version": "2.1.0", "version": "2.1.0",
"license": "Unlicense", "license": "Unlicense",
@@ -2848,6 +2992,12 @@
"tough-cookie": "^4.0.0" "tough-cookie": "^4.0.0"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"dev": true
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.0", "version": "4.0.0",
"dev": true, "dev": true,
@@ -2861,25 +3011,17 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-func-name": { "node_modules/get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@@ -2889,6 +3031,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"dev": true
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"dev": true, "dev": true,
@@ -3032,6 +3180,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"dev": true, "dev": true,
@@ -3046,6 +3214,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true
},
"node_modules/is-alphabetical": { "node_modules/is-alphabetical": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
@@ -3252,6 +3426,14 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/kysely": {
"version": "0.26.3",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.26.3.tgz",
"integrity": "sha512-yWSgGi9bY13b/W06DD2OCDDHQmq1kwTGYlQ4wpZkMOJqMGCstVCFIvxCCVG4KfY1/3G0MhDAcZsip/Lw8/vJWw==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/leac": { "node_modules/leac": {
"version": "0.6.0", "version": "0.6.0",
"license": "MIT", "license": "MIT",
@@ -3360,6 +3542,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"dev": true, "dev": true,
@@ -3371,6 +3565,21 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true
},
"node_modules/mlly": { "node_modules/mlly": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz",
@@ -3397,6 +3606,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
"integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==",
"dev": true
},
"node_modules/nearley": { "node_modules/nearley": {
"version": "2.20.1", "version": "2.20.1",
"license": "MIT", "license": "MIT",
@@ -3421,6 +3636,18 @@
"version": "2.20.3", "version": "2.20.3",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-abi": {
"version": "3.56.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.56.0.tgz",
"integrity": "sha512-fZjdhDOeRcaS+rcpve7XuwHBmktS1nS1gzgghwKUQQ8nTy2FdSDr6ZT8k6YhvlJeHmmQMYiT/IH9hfco5zeW2Q==",
"dev": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.6.7", "version": "2.6.7",
"license": "MIT", "license": "MIT",
@@ -3644,6 +3871,32 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz",
"integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -3678,6 +3931,16 @@
"version": "1.9.0", "version": "1.9.0",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
"dev": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"license": "MIT", "license": "MIT",
@@ -3708,12 +3971,41 @@
"node": ">=0.12" "node": ">=0.12"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dev": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true "dev": true
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/refractor": { "node_modules/refractor": {
"version": "4.8.1", "version": "4.8.1",
"dev": true, "dev": true,
@@ -3781,6 +4073,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"dev": true, "dev": true,
@@ -3849,6 +4161,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"dev": true, "dev": true,
@@ -3889,6 +4246,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": { "node_modules/strip-literal": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz",
@@ -3917,6 +4292,34 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tar-fs": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
"integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
"dev": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"dev": true, "dev": true,
@@ -3992,6 +4395,18 @@
"version": "2.4.1", "version": "2.4.1",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dev": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
@@ -4026,6 +4441,12 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.2.0", "version": "9.2.0",
"dev": true, "dev": true,

View File

@@ -9,6 +9,7 @@
}, },
"devDependencies": { "devDependencies": {
"@notesnook/crypto": "file:../crypto", "@notesnook/crypto": "file:../crypto",
"@types/better-sqlite3": "^7.6.5",
"@types/event-source-polyfill": "^1.0.1", "@types/event-source-polyfill": "^1.0.1",
"@types/html-to-text": "^9.0.0", "@types/html-to-text": "^9.0.0",
"@types/katex": "^0.16.2", "@types/katex": "^0.16.2",
@@ -19,6 +20,7 @@
"@types/ws": "^8.5.5", "@types/ws": "^8.5.5",
"@vitest/coverage-v8": "^0.34.1", "@vitest/coverage-v8": "^0.34.1",
"abortcontroller-polyfill": "^1.7.3", "abortcontroller-polyfill": "^1.7.3",
"better-sqlite3": "^8.6.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
@@ -54,6 +56,7 @@
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"katex": "0.16.2", "katex": "0.16.2",
"kysely": "^0.26.3",
"linkedom": "^0.14.17", "linkedom": "^0.14.17",
"liqe": "^1.13.0", "liqe": "^1.13.0",
"mime-db": "1.52.0", "mime-db": "1.52.0",

View File

@@ -60,6 +60,9 @@ import {
import TokenManager from "./token-manager"; import TokenManager from "./token-manager";
import { Attachment } from "../types"; import { Attachment } from "../types";
import { Settings } from "../collections/settings"; import { Settings } from "../collections/settings";
import { DatabaseAccessor, DatabaseSchema, createDatabase } from "../database";
import { Kysely, SqliteDriver, Transaction } from "kysely";
import BetterSQLite3 from "better-sqlite3";
type EventSourceConstructor = new ( type EventSourceConstructor = new (
uri: string, uri: string,
@@ -111,6 +114,32 @@ class Database {
return this.options.compressor; return this.options.compressor;
}; };
private _sql?: Kysely<DatabaseSchema>;
sql: DatabaseAccessor = () => {
if (this._transaction) return this._transaction;
if (!this._sql)
throw new Error(
"Database not initialized. Did you forget to call db.init()?"
);
return this._sql;
};
private _transaction?: Transaction<DatabaseSchema>;
transaction = (
executor: (tr: Transaction<DatabaseSchema>) => void | Promise<void>
) => {
if (this._transaction) return executor(this._transaction);
return this.sql()
.transaction()
.execute(async (tr) => {
this._transaction = tr;
await executor(tr);
this._transaction = undefined;
})
.finally(() => (this._transaction = undefined));
};
private options?: Options; private options?: Options;
EventSource?: EventSourceConstructor; EventSource?: EventSourceConstructor;
eventSource?: EventSource | null; eventSource?: EventSource | null;
@@ -144,6 +173,7 @@ class Database {
reminders = new Reminders(this); reminders = new Reminders(this);
relations = new Relations(this); relations = new Relations(this);
notes = new Notes(this); notes = new Notes(this);
// constructor() { // constructor() {
// this.sseMutex = new Mutex(); // this.sseMutex = new Mutex();
// // this.lastHeartbeat = undefined; // { local: 0, server: 0 }; // // this.lastHeartbeat = undefined; // { local: 0, server: 0 };
@@ -170,8 +200,8 @@ class Database {
this this
); );
EV.subscribe(EVENTS.attachmentDeleted, async (attachment: Attachment) => { EV.subscribe(EVENTS.attachmentDeleted, async (attachment: Attachment) => {
await this.fs().cancel(attachment.metadata.hash, "upload"); await this.fs().cancel(attachment.hash, "upload");
await this.fs().cancel(attachment.metadata.hash, "download"); await this.fs().cancel(attachment.hash, "download");
}); });
EV.subscribe(EVENTS.userLoggedOut, async () => { EV.subscribe(EVENTS.userLoggedOut, async () => {
await this.monographs.clear(); await this.monographs.clear();
@@ -179,6 +209,10 @@ class Database {
this.disconnectSSE(); this.disconnectSSE();
}); });
this._sql = await createDatabase(
new SqliteDriver({ database: BetterSQLite3("nn.db") })
);
await this._validate(); await this._validate();
await this.initCollections(); await this.initCollections();

View File

@@ -66,21 +66,21 @@ class Collector {
} }
} }
for (const itemType in SYNC_COLLECTIONS_MAP) { // for (const itemType in SYNC_COLLECTIONS_MAP) {
const collectionKey = // const collectionKey =
SYNC_COLLECTIONS_MAP[itemType as keyof typeof SYNC_COLLECTIONS_MAP]; // SYNC_COLLECTIONS_MAP[itemType as keyof typeof SYNC_COLLECTIONS_MAP];
const collection = this.db[collectionKey].collection; // const collection = this.db[collectionKey].collection;
for (const chunk of collection.iterateSync(chunkSize)) { // for (const chunk of collection.iterateSync(chunkSize)) {
const items = await this.prepareChunk( // const items = await this.prepareChunk(
chunk, // chunk,
lastSyncedTimestamp, // lastSyncedTimestamp,
isForceSync, // isForceSync,
key // key
); // );
if (!items) continue; // if (!items) continue;
yield { items, type: itemType as keyof typeof SYNC_COLLECTIONS_MAP }; // yield { items, type: itemType as keyof typeof SYNC_COLLECTIONS_MAP };
} // }
} // }
} }
async prepareChunk( async prepareChunk(

View File

@@ -29,21 +29,18 @@ import {
isWebClip isWebClip
} from "../utils/filename"; } from "../utils/filename";
import { Cipher, DataFormat, SerializedKey } from "@notesnook/crypto"; import { Cipher, DataFormat, SerializedKey } from "@notesnook/crypto";
import { CachedCollection } from "../database/cached-collection";
import { Output } from "../interfaces"; import { Output } from "../interfaces";
import { Attachment, AttachmentMetadata, isDeleted } from "../types"; import { Attachment } from "../types";
import Database from "../api"; import Database from "../api";
import { SQLCollection } from "../database/sql-collection";
import { isCipher } from "../database/crypto";
export class Attachments implements ICollection { export class Attachments implements ICollection {
name = "attachments"; name = "attachments";
key: Cipher<"base64"> | null = null; key: Cipher<"base64"> | null = null;
readonly collection: CachedCollection<"attachments", Attachment>; readonly collection: SQLCollection<"attachments", Attachment>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "attachments", db.eventManager);
db.storage,
"attachments",
db.eventManager
);
this.key = null; this.key = null;
EV.subscribe( EV.subscribe(
@@ -60,15 +57,15 @@ export class Attachments implements ICollection {
eventData: Record<string, unknown>; eventData: Record<string, unknown>;
}) => { }) => {
if (!success || !eventData || !eventData.readOnDownload) return; if (!success || !eventData || !eventData.readOnDownload) return;
const attachment = this.attachment(filename); const attachment = await this.attachment(filename);
if (!attachment || !attachment.metadata) return; if (!attachment) return;
const src = await this.read(filename, getOutputType(attachment)); const src = await this.read(filename, getOutputType(attachment));
if (!src) return; if (!src) return;
EV.publish(EVENTS.mediaAttachmentDownloaded, { EV.publish(EVENTS.mediaAttachmentDownloaded, {
groupId, groupId,
hash: attachment.metadata.hash, hash: attachment.hash,
attachmentType: getAttachmentType(attachment), attachmentType: getAttachmentType(attachment),
src src
}); });
@@ -86,7 +83,7 @@ export class Attachments implements ICollection {
filename: string; filename: string;
error: string; error: string;
}) => { }) => {
const attachment = this.attachment(filename); const attachment = await this.attachment(filename);
if (!attachment) return; if (!attachment) return;
if (success) await this.markAsUploaded(attachment.id); if (success) await this.markAsUploaded(attachment.id);
else else
@@ -104,54 +101,50 @@ export class Attachments implements ICollection {
async add( async add(
item: Partial< item: Partial<
Omit<Attachment, "key" | "metadata"> & { Omit<Attachment, "key" | "encryptionKey"> & {
key: SerializedKey; key: SerializedKey;
} }
> & { >
metadata: Partial<AttachmentMetadata> & { hash: string };
}
) { ) {
if (!item) return console.error("attachment cannot be undefined"); if (!item) return console.error("attachment cannot be undefined");
if (!item.metadata.hash) throw new Error("Please provide attachment hash."); if (!item.hash) throw new Error("Please provide attachment hash.");
const oldAttachment = this.all.find( const oldAttachment = await this.attachment(item.hash);
(a) => a.metadata.hash === item.metadata?.hash
);
const id = oldAttachment?.id || getId(); const id = oldAttachment?.id || getId();
const encryptedKey = item.key const encryptedKey = item.key
? await this.encryptKey(item.key) ? JSON.stringify(await this.encryptKey(item.key))
: oldAttachment?.key; : oldAttachment?.encryptionKey;
const attachment = { const attachment = {
...oldAttachment, ...oldAttachment,
...oldAttachment?.metadata,
...item, ...item,
key: encryptedKey encryptionKey: encryptedKey
}; };
const { const {
iv, iv,
length, size,
alg, alg,
hash, hash,
hashType, hashType,
filename, filename,
mimeType,
salt, salt,
type,
chunkSize, chunkSize,
key encryptionKey
} = attachment; } = attachment;
if ( if (
!iv || !iv ||
!length || !size ||
!alg || !alg ||
!hash || !hash ||
!hashType || !hashType ||
!filename || // !filename ||
// !mimeType ||
!salt || !salt ||
!chunkSize || !chunkSize ||
!key !encryptionKey
) { ) {
console.error( console.error(
"Attachment is invalid because all properties are required:", "Attachment is invalid because all properties are required:",
@@ -161,27 +154,33 @@ export class Attachments implements ICollection {
return; return;
} }
return this.collection.add({ await this.collection.upsert({
type: "attachment", type: "attachment",
id, id,
iv, iv,
salt, salt,
length, size,
alg, alg,
key, encryptionKey,
chunkSize, chunkSize,
metadata: {
hash, filename:
hashType, filename ||
filename: getFileNameWithExtension(filename, type), getFileNameWithExtension(
type: type || "application/octet-stream" filename || hash,
}, mimeType || "application/octet-stream"
),
hash,
hashType,
mimeType: mimeType || "application/octet-stream",
dateCreated: attachment.dateCreated || Date.now(), dateCreated: attachment.dateCreated || Date.now(),
dateModified: attachment.dateModified || Date.now(), dateModified: attachment.dateModified || Date.now(),
dateUploaded: attachment.dateUploaded, dateUploaded: attachment.dateUploaded,
dateDeleted: undefined,
failed: attachment.failed failed: attachment.failed
}); });
return id;
} }
async generateKey() { async generateKey() {
@@ -189,7 +188,9 @@ export class Attachments implements ICollection {
return await this.db.crypto().generateRandomKey(); return await this.db.crypto().generateRandomKey();
} }
async decryptKey(key: Cipher<"base64">): Promise<SerializedKey | null> { async decryptKey(keyJSON: string): Promise<SerializedKey | null> {
const key = JSON.parse(keyJSON);
if (!isCipher(key)) return null;
const encryptionKey = await this._getEncryptionKey(); const encryptionKey = await this._getEncryptionKey();
const plainData = await this.db.storage().decrypt(encryptionKey, key); const plainData = await this.db.storage().decrypt(encryptionKey, key);
if (!plainData) return null; if (!plainData) return null;
@@ -197,8 +198,8 @@ export class Attachments implements ICollection {
} }
async remove(hashOrId: string, localOnly: boolean) { async remove(hashOrId: string, localOnly: boolean) {
const attachment = this.attachment(hashOrId); const attachment = await this.attachment(hashOrId);
if (!attachment || !attachment.metadata) return false; if (!attachment) return false;
if (!localOnly && !(await this.canDetach(attachment))) if (!localOnly && !(await this.canDetach(attachment)))
throw new Error("This attachment is inside a locked note."); throw new Error("This attachment is inside a locked note.");
@@ -206,116 +207,115 @@ export class Attachments implements ICollection {
if ( if (
await this.db await this.db
.fs() .fs()
.deleteFile( .deleteFile(attachment.hash, localOnly || !attachment.dateUploaded)
attachment.metadata.hash,
localOnly || !attachment.dateUploaded
)
) { ) {
if (!localOnly) { if (!localOnly) {
await this.detach(attachment); await this.detach(attachment);
} }
await this.collection.remove(attachment.id); await this.collection.softDelete([attachment.id]);
return true; return true;
} }
return false; return false;
} }
async detach(attachment: Attachment) { async detach(attachment: Attachment) {
for (const note of this.db.relations.from(attachment, "note").resolved()) { for (const note of await this.db.relations
.from(attachment, "note")
.resolve()) {
if (!note || !note.contentId) continue; if (!note || !note.contentId) continue;
await this.db.content.removeAttachments(note.contentId, [ await this.db.content.removeAttachments(note.contentId, [
attachment.metadata.hash attachment.hash
]); ]);
} }
} }
private async canDetach(attachment: Attachment) { private async canDetach(attachment: Attachment) {
return this.db.relations return (await this.db.relations.from(attachment, "note").resolve()).every(
.from(attachment, "note") (note) => !note.locked
.resolved() );
.every((note) => !note.locked);
} }
ofNote( async ofNote(
noteId: string, noteId: string,
...types: ("files" | "images" | "webclips" | "all")[] ...types: ("files" | "images" | "webclips" | "all")[]
): Attachment[] { ): Promise<Attachment[]> {
const noteAttachments = this.db.relations const noteAttachments = await this.db.relations
.from({ type: "note", id: noteId }, "attachment") .from({ type: "note", id: noteId }, "attachment")
.resolved(); .resolve();
if (types.includes("all")) return noteAttachments; if (types.includes("all")) return noteAttachments;
return noteAttachments.filter((a) => { return noteAttachments.filter((a) => {
if (isImage(a.metadata.type) && types.includes("images")) return true; if (isImage(a.mimeType) && types.includes("images")) return true;
else if (isWebClip(a.metadata.type) && types.includes("webclips")) else if (isWebClip(a.mimeType) && types.includes("webclips")) return true;
return true;
else if (types.includes("files")) return true; else if (types.includes("files")) return true;
}); });
} }
exists(hash: string) { async exists(hash: string) {
const attachment = this.all.find((a) => a.metadata.hash === hash); return !!(await this.attachment(hash));
return !!attachment;
} }
async read<TOutputFormat extends DataFormat>( async read<TOutputFormat extends DataFormat>(
hash: string, hash: string,
outputType: TOutputFormat outputType: TOutputFormat
): Promise<Output<TOutputFormat> | undefined> { ): Promise<Output<TOutputFormat> | undefined> {
const attachment = this.all.find((a) => a.metadata.hash === hash); const attachment = await this.attachment(hash);
if (!attachment) return; if (!attachment) return;
const key = await this.decryptKey(attachment.key); const key = await this.decryptKey(attachment.encryptionKey);
if (!key) return; if (!key) return;
const data = await this.db const data = await this.db.fs().readEncrypted(attachment.hash, key, {
.fs() chunkSize: attachment.chunkSize,
.readEncrypted(attachment.metadata.hash, key, { iv: attachment.iv,
chunkSize: attachment.chunkSize, salt: attachment.salt,
iv: attachment.iv, size: attachment.size,
salt: attachment.salt, alg: attachment.alg,
length: attachment.length, outputType
alg: attachment.alg, });
outputType
});
if (!data) return; if (!data) return;
return ( return (
outputType === "base64" outputType === "base64"
? dataurl.fromObject({ ? dataurl.fromObject({
type: attachment.metadata.type, mimeType: attachment.mimeType,
data data
}) })
: data : data
) as Output<TOutputFormat>; ) as Output<TOutputFormat>;
} }
attachment(hashOrId: string) { async attachment(hashOrId: string): Promise<Attachment | undefined> {
return this.all.find( return await this.db
(a) => a.id === hashOrId || a.metadata?.hash === hashOrId .sql()
); .selectFrom("attachments")
.selectAll()
.where((eb) =>
eb.or([eb("id", "==", hashOrId), eb("hash", "==", hashOrId)])
)
.where("deleted", "is", null)
.$narrowType<Attachment>()
.executeTakeFirst();
} }
markAsUploaded(id: string) { markAsUploaded(id: string) {
const attachment = this.attachment(id); return this.collection.update([id], {
if (!attachment) return; dateUploaded: Date.now(),
attachment.dateUploaded = Date.now(); failed: null
attachment.failed = undefined; });
return this.collection.update(attachment);
} }
reset(id: string) { reset(id: string) {
const attachment = this.attachment(id); return this.collection.update([id], {
if (!attachment) return; dateUploaded: null
attachment.dateUploaded = undefined; });
return this.collection.update(attachment);
} }
markAsFailed(id: string, reason: string) { markAsFailed(id: string, reason: string) {
const attachment = this.attachment(id); return this.collection.update([id], {
if (!attachment) return; dateUploaded: null,
attachment.failed = reason; failed: reason
return this.collection.update(attachment); });
} }
async save( async save(
@@ -325,7 +325,7 @@ export class Attachments implements ICollection {
): Promise<string | undefined> { ): Promise<string | undefined> {
const hashResult = await this.db.fs().hashBase64(data); const hashResult = await this.db.fs().hashBase64(data);
if (!hashResult) return; if (!hashResult) return;
if (this.exists(hashResult.hash)) return hashResult.hash; if (await this.exists(hashResult.hash)) return hashResult.hash;
const key = await this.generateKey(); const key = await this.generateKey();
const { hash, hashType, ...encryptionMetadata } = await this.db const { hash, hashType, ...encryptionMetadata } = await this.db
@@ -335,27 +335,23 @@ export class Attachments implements ICollection {
await this.add({ await this.add({
...encryptionMetadata, ...encryptionMetadata,
key, key,
metadata: {
filename: filename || hash, filename: filename || hash,
hash, hash,
hashType, hashType,
type: mimeType || "application/octet-stream" mimeType: mimeType || "application/octet-stream"
}
}); });
return hash; return hash;
} }
async downloadMedia(noteId: string, hashesToLoad?: string[]) { async downloadMedia(noteId: string, hashesToLoad?: string[]) {
let attachments = this.ofNote(noteId, "images", "webclips"); let attachments = await this.ofNote(noteId, "images", "webclips");
if (hashesToLoad) if (hashesToLoad)
attachments = attachments.filter((a) => attachments = attachments.filter((a) => hasItem(hashesToLoad, a.hash));
hasItem(hashesToLoad, a.metadata.hash)
);
await this.db.fs().queueDownloads( await this.db.fs().queueDownloads(
attachments.map((a) => ({ attachments.map((a) => ({
filename: a.metadata.hash, filename: a.hash,
metadata: a.metadata,
chunkSize: a.chunkSize chunkSize: a.chunkSize
})), })),
noteId, noteId,
@@ -375,65 +371,60 @@ export class Attachments implements ICollection {
const isDeleted = await this.db.fs().deleteFile(attachment.metadata.hash); const isDeleted = await this.db.fs().deleteFile(attachment.metadata.hash);
if (!isDeleted) continue; if (!isDeleted) continue;
await this.collection.remove(attachment.id); await this.collection.softDelete(attachment.id);
} }
} }
get pending() { // get pending() {
return this.all.filter( // return this.all.filter(
(attachment) => !attachment.dateUploaded || attachment.dateUploaded <= 0 // (attachment) =>
); // (!attachment.dateUploaded || attachment.dateUploaded <= 0) &&
} // this.db.relations.to(attachment, "note").length > 0
// );
// }
get uploaded() { // get uploaded() {
return this.all.filter((attachment) => !!attachment.dateUploaded); // return this.all.filter((attachment) => !!attachment.dateUploaded);
} // }
get syncable() { // get syncable() {
return this.collection // return this.collection
.raw() // .raw()
.filter( // .filter(
(attachment) => isDeleted(attachment) || !!attachment.dateUploaded // (attachment) => isDeleted(attachment) || !!attachment.dateUploaded
); // );
} // }
get deleted() { // get deleted() {
return this.all.filter((attachment) => !!attachment.dateDeleted); // return this.all.filter((attachment) => !!attachment.dateDeleted);
} // }
get images() { // get images() {
return this.all.filter( // return this.all.filter((attachment) => isImage(attachment.metadata.type));
(attachment) => attachment.metadata && isImage(attachment.metadata.type) // }
);
}
get webclips() { // get webclips() {
return this.all.filter( // return this.all.filter((attachment) => isWebClip(attachment.metadata.type));
(attachment) => attachment.metadata && isWebClip(attachment.metadata.type) // }
);
}
get media() { // get media() {
return this.all.filter( // return this.all.filter(
(attachment) => // (attachment) =>
attachment.metadata && // isImage(attachment.metadata.type) || isWebClip(attachment.metadata.type)
(isImage(attachment.metadata.type) || // );
isWebClip(attachment.metadata.type)) // }
);
}
get files() { // get files() {
return this.all.filter( // return this.all.filter(
(attachment) => // (attachment) =>
attachment.metadata && // !isImage(attachment.metadata.type) &&
!isImage(attachment.metadata.type) && // !isWebClip(attachment.metadata.type)
!isWebClip(attachment.metadata.type) // );
); // }
}
get all() { // get all() {
return this.collection.items(); // return this.collection.items();
} // }
private async encryptKey(key: SerializedKey) { private async encryptKey(key: SerializedKey) {
const encryptionKey = await this._getEncryptionKey(); const encryptionKey = await this._getEncryptionKey();
@@ -454,15 +445,15 @@ export class Attachments implements ICollection {
} }
export function getOutputType(attachment: Attachment): DataFormat { export function getOutputType(attachment: Attachment): DataFormat {
if (attachment.metadata.type === "application/vnd.notesnook.web-clip") if (attachment.mimeType === "application/vnd.notesnook.web-clip")
return "text"; return "text";
else if (attachment.metadata.type.startsWith("image/")) return "base64"; else if (attachment.mimeType.startsWith("image/")) return "base64";
return "uint8array"; return "uint8array";
} }
function getAttachmentType(attachment: Attachment) { function getAttachmentType(attachment: Attachment) {
if (attachment.metadata.type === "application/vnd.notesnook.web-clip") if (attachment.mimeType === "application/vnd.notesnook.web-clip")
return "webclip"; return "webclip";
else if (attachment.metadata.type.startsWith("image/")) return "image"; else if (attachment.mimeType.startsWith("image/")) return "image";
else return "generic"; else return "generic";
} }

View File

@@ -19,10 +19,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { getId } from "../utils/id"; import { getId } from "../utils/id";
import { Color, MaybeDeletedItem } from "../types"; import { Color } from "../types";
import Database from "../api"; import Database from "../api";
import { CachedCollection } from "../database/cached-collection";
import { Tags } from "./tags"; import { Tags } from "./tags";
import { SQLCollection } from "../database/sql-collection";
export const DefaultColors: Record<string, string> = { export const DefaultColors: Record<string, string> = {
red: "#f44336", red: "#f44336",
@@ -36,13 +36,9 @@ export const DefaultColors: Record<string, string> = {
export class Colors implements ICollection { export class Colors implements ICollection {
name = "colors"; name = "colors";
readonly collection: CachedCollection<"colors", Color>; readonly collection: SQLCollection<"colors", Color>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "colors", db.eventManager);
db.storage,
"colors",
db.eventManager
);
} }
init() { init() {
@@ -53,27 +49,27 @@ export class Colors implements ICollection {
return this.collection.get(id); return this.collection.get(id);
} }
async merge(remoteColor: MaybeDeletedItem<Color>) { // async merge(remoteColor: MaybeDeletedItem<Color>) {
if (!remoteColor) return; // if (!remoteColor) return;
const localColor = this.collection.get(remoteColor.id); // const localColor = this.collection.get(remoteColor.id);
if (!localColor || remoteColor.dateModified > localColor.dateModified) // if (!localColor || remoteColor.dateModified > localColor.dateModified)
await this.collection.add(remoteColor); // await this.collection.add(remoteColor);
} // }
async add(item: Partial<Color>) { async add(item: Partial<Color>) {
if (item.remote) if (item.remote)
throw new Error("Please use db.colors.merge to merge remote colors."); throw new Error("Please use db.colors.merge to merge remote colors.");
const id = item.id || getId(item.dateCreated); const id = item.id || getId(item.dateCreated);
const oldColor = this.color(id); const oldColor = await this.color(id);
item.title = item.title ? Tags.sanitize(item.title) : item.title; item.title = item.title ? Tags.sanitize(item.title) : item.title;
if (!item.title && !oldColor?.title) throw new Error("Title is required."); if (!item.title && !oldColor?.title) throw new Error("Title is required.");
if (!item.colorCode && !oldColor?.colorCode) if (!item.colorCode && !oldColor?.colorCode)
throw new Error("Color code is required."); throw new Error("Color code is required.");
const color: Color = { await this.collection.upsert({
id, id,
dateCreated: item.dateCreated || oldColor?.dateCreated || Date.now(), dateCreated: item.dateCreated || oldColor?.dateCreated || Date.now(),
dateModified: item.dateModified || oldColor?.dateModified || Date.now(), dateModified: item.dateModified || oldColor?.dateModified || Date.now(),
@@ -81,34 +77,35 @@ export class Colors implements ICollection {
colorCode: item.colorCode || oldColor?.colorCode || "", colorCode: item.colorCode || oldColor?.colorCode || "",
type: "color", type: "color",
remote: false remote: false
}; });
await this.collection.add(color); return id;
return color.id;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
// }
// get all(): Color[] {
// return this.collection.items();
// }
async remove(...ids: string[]) {
await this.db.transaction(async () => {
await this.db.relations.unlinkOfType("color", ids);
await this.collection.softDelete(ids);
});
} }
get all(): Color[] { // async delete(id: string) {
return this.collection.items(); // await this.collection.delete(id);
} // await this.db.relations.cleanup();
// }
async remove(id: string) {
await this.collection.remove(id);
await this.db.relations.cleanup();
}
async delete(id: string) {
await this.collection.delete(id);
await this.db.relations.cleanup();
}
exists(id: string) { exists(id: string) {
return this.collection.exists(id); return this.collection.exists(id);
} }
find(idOrTitle: string) { // find(idOrTitle: string) {
return this.all.find((t) => t.title === idOrTitle || t.id === idOrTitle); // return this.all.find((t) => t.title === idOrTitle || t.id === idOrTitle);
} // }
} }

View File

@@ -27,13 +27,12 @@ import {
ContentItem, ContentItem,
ContentType, ContentType,
EncryptedContentItem, EncryptedContentItem,
MaybeDeletedItem,
UnencryptedContentItem, UnencryptedContentItem,
isDeleted isDeleted
} from "../types"; } from "../types";
import { IndexedCollection } from "../database/indexed-collection";
import Database from "../api"; import Database from "../api";
import { getOutputType } from "./attachments"; import { getOutputType } from "./attachments";
import { SQLCollection } from "../database/sql-collection";
export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({ export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({
noteId, noteId,
@@ -48,27 +47,15 @@ export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({
export class Content implements ICollection { export class Content implements ICollection {
name = "content"; name = "content";
readonly collection: IndexedCollection<"content", ContentItem>; readonly collection: SQLCollection<"content", ContentItem>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new IndexedCollection( this.collection = new SQLCollection(db.sql, "content", db.eventManager);
db.storage,
"content",
db.eventManager
);
} }
async init() { async init() {
await this.collection.init(); await this.collection.init();
} }
async merge(content: MaybeDeletedItem<ContentItem>) {
return await this.collection.addItem(
isDeleted(content) || !isUnencryptedContent(content)
? content
: await this.extractAttachments(content)
);
}
async add(content: Partial<ContentItem>) { async add(content: Partial<ContentItem>) {
if (typeof content.data === "object") { if (typeof content.data === "object") {
if ("data" in content.data && typeof content.data.data === "string") if ("data" in content.data && typeof content.data.data === "string")
@@ -106,7 +93,7 @@ export class Content implements ICollection {
conflicted: content.conflicted, conflicted: content.conflicted,
dateResolved: content.dateResolved dateResolved: content.dateResolved
}; };
await this.collection.addItem( await this.collection.upsert(
isUnencryptedContent(contentItem) isUnencryptedContent(contentItem)
? await this.extractAttachments(contentItem) ? await this.extractAttachments(contentItem)
: contentItem : contentItem
@@ -133,29 +120,46 @@ export class Content implements ICollection {
} }
async raw(id: string) { async raw(id: string) {
const content = await this.collection.getItem(id); const content = await this.collection.get(id);
if (!content) return; if (!content) return;
return content; return content;
} }
remove(id: string) { remove(...ids: string[]) {
if (!id) return; return this.collection.softDelete(ids);
return this.collection.removeItem(id);
} }
multi(ids: string[]) { removeByNoteId(...ids: string[]) {
return this.collection.getItems(ids); return this.db
.sql()
.replaceInto("content")
.columns(["id", "dateModified", "deleted"])
.expression((eb) =>
eb
.selectFrom("content")
.where("noteId", "in", ids)
.select((eb) => [
"content.id",
eb.lit(Date.now()).as("dateModified"),
eb.lit(1).as("deleted")
])
)
.execute();
} }
// multi(ids: string[]) {
// return this.collection.getItems(ids);
// }
exists(id: string) { exists(id: string) {
return this.collection.exists(id); return this.collection.exists(id);
} }
async all() { // async all() {
return Object.values( // return Object.values(
await this.collection.getItems(this.collection.indexer.indices) // await this.collection.getItems(this.collection.indexer.indices)
); // );
} // }
insertMedia(contentItem: UnencryptedContentItem) { insertMedia(contentItem: UnencryptedContentItem) {
return this.insert(contentItem, async (hashes) => { return this.insert(contentItem, async (hashes) => {
@@ -183,17 +187,16 @@ export class Content implements ICollection {
const content = getContentFromData(contentItem.type, contentItem.data); const content = getContentFromData(contentItem.type, contentItem.data);
if (!content) return contentItem; if (!content) return contentItem;
contentItem.data = await content.insertMedia(async (hashes) => { contentItem.data = await content.insertMedia(async (hashes) => {
const attachments = hashes.reduce((attachments, hash) => { const attachments: Attachment[] = [];
const attachment = this.db.attachments.attachment(hash); for (const hash of hashes) {
if (!attachment) return attachments; const attachment = await this.db.attachments.attachment(hash);
if (!attachment) continue;
attachments.push(attachment); attachments.push(attachment);
return attachments; }
}, [] as Attachment[]);
await this.db.fs().queueDownloads( await this.db.fs().queueDownloads(
attachments.map((a) => ({ attachments.map((a) => ({
filename: a.metadata.hash, filename: a.hash,
metadata: a.metadata,
chunkSize: a.chunkSize chunkSize: a.chunkSize
})), })),
groupId, groupId,
@@ -203,11 +206,11 @@ export class Content implements ICollection {
const sources: Record<string, string> = {}; const sources: Record<string, string> = {};
for (const attachment of attachments) { for (const attachment of attachments) {
const src = await this.db.attachments.read( const src = await this.db.attachments.read(
attachment.metadata.hash, attachment.hash,
getOutputType(attachment) getOutputType(attachment)
); );
if (!src) continue; if (!src) continue;
sources[attachment.metadata.hash] = src; sources[attachment.hash] = src;
} }
return sources; return sources;
}); });
@@ -243,16 +246,16 @@ export class Content implements ICollection {
this.db.attachments.save this.db.attachments.save
); );
const noteAttachments = this.db.relations const noteAttachments = await this.db.relations
.from({ type: "note", id: contentItem.noteId }, "attachment") .from({ type: "note", id: contentItem.noteId }, "attachment")
.resolved(); .resolve();
const toDelete = noteAttachments.filter((attachment) => { const toDelete = noteAttachments.filter((attachment) => {
return hashes.every((hash) => hash !== attachment.metadata.hash); return hashes.every((hash) => hash !== attachment.hash);
}); });
const toAdd = hashes.filter((hash) => { const toAdd = hashes.filter((hash) => {
return hash && noteAttachments.every((a) => hash !== a.metadata.hash); return hash && noteAttachments.every((a) => hash !== a.hash);
}); });
for (const attachment of toDelete) { for (const attachment of toDelete) {
@@ -266,7 +269,7 @@ export class Content implements ICollection {
} }
for (const hash of toAdd) { for (const hash of toAdd) {
const attachment = this.db.attachments.attachment(hash); const attachment = await this.db.attachments.attachment(hash);
if (!attachment) continue; if (!attachment) continue;
await this.db.relations.add( await this.db.relations.add(
{ {
@@ -284,24 +287,24 @@ export class Content implements ICollection {
return contentItem; return contentItem;
} }
async cleanup() { // async cleanup() {
const indices = this.collection.indexer.indices; // const indices = this.collection.indexer.indices;
await this.db.notes.init(); // await this.db.notes.init();
const notes = this.db.notes.all; // const notes = this.db.notes.all;
if (!notes.length && indices.length > 0) return []; // if (!notes.length && indices.length > 0) return [];
const ids = []; // const ids = [];
for (const contentId of indices) { // for (const contentId of indices) {
const noteIndex = notes.findIndex((note) => note.contentId === contentId); // const noteIndex = notes.findIndex((note) => note.contentId === contentId);
const isOrphaned = noteIndex === -1; // const isOrphaned = noteIndex === -1;
if (isOrphaned) { // if (isOrphaned) {
ids.push(contentId); // ids.push(contentId);
await this.collection.deleteItem(contentId); // await this.collection.deleteItem(contentId);
} else if (notes[noteIndex].localOnly) { // } else if (notes[noteIndex].localOnly) {
ids.push(contentId); // ids.push(contentId);
} // }
} // }
return ids; // return ids;
} // }
} }
export function isUnencryptedContent( export function isUnencryptedContent(

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import Database from "../api"; import Database from "../api";
import { isCipher } from "../database/crypto"; import { isCipher } from "../database/crypto";
import { IndexedCollection } from "../database/indexed-collection"; import { SQLCollection } from "../database/sql-collection";
import { HistorySession, isDeleted } from "../types"; import { HistorySession, isDeleted } from "../types";
import { makeSessionContentId } from "../utils/id"; import { makeSessionContentId } from "../utils/id";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
@@ -29,13 +29,9 @@ export class NoteHistory implements ICollection {
name = "notehistory"; name = "notehistory";
versionsLimit = 100; versionsLimit = 100;
sessionContent = new SessionContent(this.db); sessionContent = new SessionContent(this.db);
private readonly collection: IndexedCollection<"notehistory", HistorySession>; private readonly collection: SQLCollection<"notehistory", HistorySession>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new IndexedCollection( this.collection = new SQLCollection(db.sql, "notehistory", db.eventManager);
db.storage,
"notehistory",
db.eventManager
);
} }
async init() { async init() {
@@ -43,10 +39,6 @@ export class NoteHistory implements ICollection {
await this.sessionContent.init(); await this.sessionContent.init();
} }
async merge(item: HistorySession) {
await this.collection.addItem(item);
}
async get(noteId: string) { async get(noteId: string) {
if (!noteId) return []; if (!noteId) return [];
@@ -67,7 +59,7 @@ export class NoteHistory implements ICollection {
content: NoteContent<boolean> content: NoteContent<boolean>
) { ) {
sessionId = `${noteId}_${sessionId}`; sessionId = `${noteId}_${sessionId}`;
const oldSession = await this.collection.getItem(sessionId); const oldSession = await this.collection.get(sessionId);
if (oldSession && isDeleted(oldSession)) return; if (oldSession && isDeleted(oldSession)) return;
@@ -82,7 +74,7 @@ export class NoteHistory implements ICollection {
locked locked
}; };
await this.collection.addItem(session); await this.collection.upsert(session);
await this.sessionContent.add(sessionId, content, locked); await this.sessionContent.add(sessionId, content, locked);
await this.cleanup(noteId); await this.cleanup(noteId);
@@ -104,19 +96,18 @@ export class NoteHistory implements ICollection {
} }
async content(sessionId: string) { async content(sessionId: string) {
const session = await this.collection.getItem(sessionId); const session = await this.collection.get(sessionId);
if (!session || isDeleted(session)) return; if (!session || isDeleted(session)) return;
return await this.sessionContent.get(session.sessionContentId); return await this.sessionContent.get(session.sessionContentId);
} }
async remove(sessionId: string) { async remove(sessionId: string) {
const session = await this.collection.getItem(sessionId); const session = await this.collection.get(sessionId);
if (!session || isDeleted(session)) return; if (!session || isDeleted(session)) return;
await this._remove(session); await this._remove(session);
} }
async clearSessions(noteId: string) { async clearSessions(...noteIds: string[]) {
if (!noteId) return;
const history = await this.get(noteId); const history = await this.get(noteId);
for (const item of history) { for (const item of history) {
await this._remove(item); await this._remove(item);
@@ -124,12 +115,12 @@ export class NoteHistory implements ICollection {
} }
private async _remove(session: HistorySession) { private async _remove(session: HistorySession) {
await this.collection.deleteItem(session.id); await this.collection.delete(session.id);
await this.sessionContent.remove(session.sessionContentId); await this.sessionContent.remove(session.sessionContentId);
} }
async restore(sessionId: string) { async restore(sessionId: string) {
const session = await this.collection.getItem(sessionId); const session = await this.collection.get(sessionId);
if (!session || isDeleted(session)) return; if (!session || isDeleted(session)) return;
const content = await this.sessionContent.get(session.sessionContentId); const content = await this.sessionContent.get(session.sessionContentId);
@@ -153,14 +144,14 @@ export class NoteHistory implements ICollection {
} }
} }
async all() { // async all() {
return this.getSessions(this.collection.indexer.indices); // return this.getSessions(this.collection.indexer.indices);
} // }
private async getSessions(sessionIds: string[]): Promise<HistorySession[]> { // private async getSessions(sessionIds: string[]): Promise<HistorySession[]> {
const items = await this.collection.getItems(sessionIds); // const items = await this.collection.getItems(sessionIds);
return Object.values(items).filter( // return Object.values(items).filter(
(a) => !isDeleted(a) // (a) => !isDeleted(a)
) as HistorySession[]; // ) as HistorySession[];
} // }
} }

View File

@@ -17,25 +17,20 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { createNotebookModel } from "../models/notebook";
import { getId } from "../utils/id"; import { getId } from "../utils/id";
import { CachedCollection } from "../database/cached-collection";
import Database from "../api"; import Database from "../api";
import { BaseTrashItem, Notebook, TrashOrItem, isTrashItem } from "../types"; import { Notebook, TrashOrItem, isTrashItem } from "../types";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { SQLCollection } from "../database/sql-collection";
export class Notebooks implements ICollection { export class Notebooks implements ICollection {
name = "notebooks"; name = "notebooks";
/** /**
* @internal * @internal
*/ */
collection: CachedCollection<"notebooks", TrashOrItem<Notebook>>; collection: SQLCollection<"notebooks", TrashOrItem<Notebook>>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "notebooks", db.eventManager);
db.storage,
"notebooks",
db.eventManager
);
} }
init() { init() {
@@ -51,7 +46,7 @@ export class Notebooks implements ICollection {
//TODO reliably and efficiently check for duplicates. //TODO reliably and efficiently check for duplicates.
const id = notebookArg.id || getId(); const id = notebookArg.id || getId();
const oldNotebook = this.collection.get(id); const oldNotebook = await this.notebook(id);
if (oldNotebook && isTrashItem(oldNotebook)) if (oldNotebook && isTrashItem(oldNotebook))
throw new Error("Cannot modify trashed notebooks."); throw new Error("Cannot modify trashed notebooks.");
@@ -64,7 +59,7 @@ export class Notebooks implements ICollection {
if (!mergedNotebook.title) if (!mergedNotebook.title)
throw new Error("Notebook must contain a title."); throw new Error("Notebook must contain a title.");
const notebook: Notebook = { await this.collection.upsert({
id, id,
type: "notebook", type: "notebook",
title: mergedNotebook.title, title: mergedNotebook.title,
@@ -74,79 +69,87 @@ export class Notebooks implements ICollection {
dateCreated: mergedNotebook.dateCreated || Date.now(), dateCreated: mergedNotebook.dateCreated || Date.now(),
dateModified: mergedNotebook.dateModified || Date.now(), dateModified: mergedNotebook.dateModified || Date.now(),
dateEdited: Date.now() dateEdited: Date.now()
}; });
await this.collection.add(notebook);
return id; return id;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
get all() { // get all() {
return this.collection.items((note) => // return this.collection.items((note) =>
isTrashItem(note) ? undefined : note // isTrashItem(note) ? undefined : note
) as Notebook[]; // ) as Notebook[];
} // }
get pinned() { // get pinned() {
return this.all.filter((item) => item.pinned === true); // return this.all.filter((item) => item.pinned === true);
} // }
get trashed() { // get trashed() {
return this.raw.filter((item) => // return this.raw.filter((item) =>
isTrashItem(item) // isTrashItem(item)
) as BaseTrashItem<Notebook>[]; // ) as BaseTrashItem<Notebook>[];
} // }
async pin(...ids: string[]) { async pin(...ids: string[]) {
for (const id of ids) { await this.collection.update(ids, { pinned: true });
if (!this.exists(id)) continue;
await this.add({ id, pinned: true });
}
} }
async unpin(...ids: string[]) { async unpin(...ids: string[]) {
for (const id of ids) { await this.collection.update(ids, { pinned: false });
if (!this.exists(id)) continue;
await this.add({ id, pinned: false });
}
} }
totalNotes(id: string) { async totalNotes(id: string) {
let count = 0; const result = await this.db
const subNotebooks = this.db.relations.from( .sql()
{ type: "notebook", id }, .withRecursive(`subNotebooks(id)`, (eb) =>
"notebook" eb
); .selectNoFrom((eb) => eb.val(id).as("id"))
for (const notebook of subNotebooks) { .unionAll((eb) =>
count += this.totalNotes(notebook.to.id); eb
} .selectFrom(["relations", "subNotebooks"])
count += this.db.relations.from({ type: "notebook", id }, "note").length; .select("relations.toId as id")
return count; .where("toType", "==", "notebook")
.where("fromType", "==", "notebook")
.whereRef("fromId", "==", "subNotebooks.id")
.where("toId", "not in", this.db.trash.cache)
.$narrowType<{ id: string }>()
)
)
.selectFrom("relations")
.where("toType", "==", "note")
.where("fromType", "==", "notebook")
.where("fromId", "in", (eb) =>
eb.selectFrom("subNotebooks").select("subNotebooks.id")
)
.where("toId", "not in", this.db.trash.cache)
.select((eb) => eb.fn.count<number>("id").as("totalNotes"))
.executeTakeFirst();
if (!result) return 0;
return result.totalNotes;
} }
notebook(idOrNotebook: string | Notebook) { async notebook(id: string) {
const notebook = const notebook = await this.collection.get(id);
typeof idOrNotebook === "string"
? this.collection.get(idOrNotebook)
: idOrNotebook;
if (!notebook || isTrashItem(notebook)) return; if (!notebook || isTrashItem(notebook)) return;
return createNotebookModel(notebook, this.db); return notebook;
} }
exists(id: string) { exists(id: string) {
return this.collection.exists(id); return this.collection.exists(id);
} }
async remove(...ids: string[]) {
await this.db.trash.add("notebook", ids);
}
async delete(...ids: string[]) { async delete(...ids: string[]) {
for (const id of ids) { await this.db.transaction(async () => {
const notebook = this.collection.get(id); await this.db.relations.unlinkOfType("notebook", ids);
if (!notebook || isTrashItem(notebook)) continue; await this.collection.softDelete(ids);
await this.collection.remove(id); });
await this.db.shortcuts?.remove(id);
await this.db.trash?.add(notebook);
}
} }
} }

View File

@@ -17,7 +17,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { createNoteModel } from "../models/note";
import { getId } from "../utils/id"; import { getId } from "../utils/id";
import { getContentFromData } from "../content-types"; import { getContentFromData } from "../content-types";
import { NEWLINE_STRIP_REGEX, formatTitle } from "../utils/title-format"; import { NEWLINE_STRIP_REGEX, formatTitle } from "../utils/title-format";
@@ -26,17 +25,11 @@ import { Tiptap } from "../content-types/tiptap";
import { EMPTY_CONTENT, isUnencryptedContent } from "./content"; import { EMPTY_CONTENT, isUnencryptedContent } from "./content";
import { CHECK_IDS, checkIsUserPremium } from "../common"; import { CHECK_IDS, checkIsUserPremium } from "../common";
import { buildFromTemplate } from "../utils/templates"; import { buildFromTemplate } from "../utils/templates";
import { import { Note, TrashOrItem, isTrashItem, isDeleted } from "../types";
Note,
TrashOrItem,
isTrashItem,
isDeleted,
BaseTrashItem
} from "../types";
import Database from "../api"; import Database from "../api";
import { CachedCollection } from "../database/cached-collection";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { NoteContent } from "./session-content"; import { NoteContent } from "./session-content";
import { SQLCollection } from "../database/sql-collection";
type ExportOptions = { type ExportOptions = {
format: "html" | "md" | "txt" | "md-frontmatter"; format: "html" | "md" | "txt" | "md-frontmatter";
@@ -50,17 +43,15 @@ export class Notes implements ICollection {
/** /**
* @internal * @internal
*/ */
collection: CachedCollection<"notes", TrashOrItem<Note>>; collection: SQLCollection<"notes", TrashOrItem<Note>>;
totalNotes = 0;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "notes", db.eventManager);
db.storage,
"notes",
db.eventManager
);
} }
async init() { async init() {
await this.collection.init(); await this.collection.init();
this.totalNotes = await this.collection.count();
} }
async add( async add(
@@ -70,9 +61,7 @@ export class Notes implements ICollection {
throw new Error("Please use db.notes.merge to merge remote notes."); throw new Error("Please use db.notes.merge to merge remote notes.");
const id = item.id || getId(); const id = item.id || getId();
const oldNote = this.collection.get(id); const oldNote = await this.note(id);
if (oldNote && isTrashItem(oldNote))
throw new Error("Cannot modify trashed notes.");
const note = { const note = {
...oldNote, ...oldNote,
@@ -110,10 +99,10 @@ export class Notes implements ICollection {
}); });
} }
const noteTitle = this.getNoteTitle(note, oldNote, note.headline); const noteTitle = await this.getNoteTitle(note, oldNote, note.headline);
if (oldNote && oldNote.title !== noteTitle) note.dateEdited = Date.now(); if (oldNote && oldNote.title !== noteTitle) note.dateEdited = Date.now();
await this.collection.add({ await this.collection.upsert({
id, id,
contentId: note.contentId, contentId: note.contentId,
type: "note", type: "note",
@@ -136,54 +125,61 @@ export class Notes implements ICollection {
dateModified: note.dateModified || Date.now() dateModified: note.dateModified || Date.now()
}); });
if (!oldNote) this.totalNotes++;
return id; return id;
} }
note(idOrNote: string | Note) { async note(idOrNote: string) {
if (!idOrNote) return; const note = await this.collection.get(idOrNote);
const note = if (!note || isTrashItem(note) || isDeleted(note)) return;
typeof idOrNote === "object" ? idOrNote : this.collection.get(idOrNote); return note;
if (!note || isTrashItem(note)) return;
return createNoteModel(note, this.db);
} }
get raw() { // note(idOrNote: string | Note) {
return this.collection.raw(); // if (!idOrNote) return;
} // const note =
// typeof idOrNote === "object" ? idOrNote : this.collection.get(idOrNote);
// if (!note || isTrashItem(note)) return;
// return createNoteModel(note, this.db);
// }
get all() { // get raw() {
return this.collection.items((note) => // return this.collection.raw();
isTrashItem(note) ? undefined : note // }
) as Note[];
}
isTrashed(id: string) { // get all() {
return this.raw.find((item) => item.id === id && isTrashItem(item)); // return this.collection.items((note) =>
} // isTrashItem(note) ? undefined : note
// ) as Note[];
// }
get trashed() { // isTrashed(id: string) {
return this.raw.filter((item) => // return this.raw.find((item) => item.id === id && isTrashItem(item));
isTrashItem(item) // }
) as BaseTrashItem<Note>[];
}
get pinned() { // get trashed() {
return this.all.filter((item) => item.pinned === true); // return this.raw.filter((item) =>
} // isTrashItem(item)
// ) as BaseTrashItem<Note>[];
// }
get conflicted() { // get pinned() {
return this.all.filter((item) => item.conflicted === true); // return this.all.filter((item) => item.pinned === true);
} // }
get favorites() { // get conflicted() {
return this.all.filter((item) => item.favorite === true); // return this.all.filter((item) => item.conflicted === true);
} // }
get locked(): Note[] { // get favorites() {
return this.all.filter( // return this.all.filter((item) => item.favorite === true);
(item) => !isTrashItem(item) && item.locked === true // }
) as Note[];
} // get locked(): Note[] {
// return this.all.filter(
// (item) => !isTrashItem(item) && item.locked === true
// ) as Note[];
// }
exists(id: string) { exists(id: string) {
return this.collection.exists(id); return this.collection.exists(id);
@@ -202,7 +198,7 @@ export class Notes implements ICollection {
if (format !== "txt" && !(await checkIsUserPremium(CHECK_IDS.noteExport))) if (format !== "txt" && !(await checkIsUserPremium(CHECK_IDS.noteExport)))
return false; return false;
const note = this.note(id); const note = await this.note(id);
if (!note) return false; if (!note) return false;
if (!options.contentItem) { if (!options.contentItem) {
@@ -239,15 +235,15 @@ export class Notes implements ICollection {
return options?.disableTemplate return options?.disableTemplate
? contentString ? contentString
: buildFromTemplate(format, { : buildFromTemplate(format, {
...note.data, ...note,
content: contentString content: contentString
}); });
} }
async duplicate(...ids: string[]) { async duplicate(...ids: string[]) {
for (const id of ids) { for (const id of ids) {
const note = this.collection.get(id); const note = await this.note(id);
if (!note || isTrashItem(note)) continue; if (!note) continue;
const content = note.contentId const content = note.contentId
? await this.db.content.raw(note.contentId) ? await this.db.content.raw(note.contentId)
@@ -274,13 +270,16 @@ export class Notes implements ICollection {
}); });
if (!duplicateId) return; if (!duplicateId) return;
for (const notebook of this.db.relations for (const notebook of await this.db.relations
.to(note, "notebook") .to(note, "notebook")
.resolved()) { .get()) {
await this.db.relations.add(notebook, { await this.db.relations.add(
id: duplicateId, { type: "notebook", id: notebook },
type: "note" {
}); id: duplicateId,
type: "note"
}
);
} }
return duplicateId; return duplicateId;
@@ -288,24 +287,17 @@ export class Notes implements ICollection {
} }
private async _delete(moveToTrash = true, ...ids: string[]) { private async _delete(moveToTrash = true, ...ids: string[]) {
for (const id of ids) { if (moveToTrash) {
const item = this.collection.get(id); await this.db.trash.add("note", ids);
if (!item) continue; } else {
const itemData = clone(item); await this.db.transaction(async () => {
await this.db.relations.unlinkOfType("note", ids);
if (moveToTrash && !isTrashItem(itemData)) await this.collection.softDelete(ids);
await this.db.trash.add(itemData); await this.db.content.removeByNoteId(...ids);
else { });
await this.db.relations.unlinkAll(item, "tag");
await this.db.relations.unlinkAll(item, "color");
await this.db.relations.unlinkAll(item, "attachment");
await this.db.relations.unlinkAll(item, "notebook");
await this.collection.remove(id);
if (itemData.contentId)
await this.db.content.remove(itemData.contentId);
}
} }
this.totalNotes = Math.max(0, this.totalNotes - ids.length);
} }
async addToNotebook(notebookId: string, ...noteIds: string[]) { async addToNotebook(notebookId: string, ...noteIds: string[]) {
@@ -318,24 +310,31 @@ export class Notes implements ICollection {
} }
async removeFromNotebook(notebookId: string, ...noteIds: string[]) { async removeFromNotebook(notebookId: string, ...noteIds: string[]) {
for (const noteId of noteIds) { await this.db.transaction(async () => {
await this.db.relations.unlink( for (const noteId of noteIds) {
{ id: notebookId, type: "notebook" }, await this.db.relations.unlink(
{ type: "note", id: noteId } { id: notebookId, type: "notebook" },
); { type: "note", id: noteId }
} );
}
});
} }
async removeFromAllNotebooks(...noteIds: string[]) { async removeFromAllNotebooks(...noteIds: string[]) {
for (const noteId of noteIds) { await this.db.transaction(async () => {
await this.db.relations.unlinkAll( for (const noteId of noteIds) {
{ type: "note", id: noteId }, await this.db.relations
"notebook" .to({ type: "note", id: noteId }, "notebook")
); .unlink();
} }
});
} }
private getNoteTitle(note: Partial<Note>, oldNote?: Note, headline?: string) { private async getNoteTitle(
note: Partial<Note>,
oldNote?: Note,
headline?: string
) {
if (note.title && note.title.trim().length > 0) { if (note.title && note.title.trim().length > 0) {
return note.title.replace(NEWLINE_STRIP_REGEX, " "); return note.title.replace(NEWLINE_STRIP_REGEX, " ");
} else if ( } else if (
@@ -352,7 +351,7 @@ export class Notes implements ICollection {
this.db.settings.getDateFormat(), this.db.settings.getDateFormat(),
this.db.settings.getTimeFormat(), this.db.settings.getTimeFormat(),
headline?.split(" ").splice(0, 10).join(" "), headline?.split(" ").splice(0, 10).join(" "),
this.collection.count() this.totalNotes
); );
} }
} }

View File

@@ -17,197 +17,113 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { CachedCollection } from "../database/cached-collection";
import { makeId } from "../utils/id"; import { makeId } from "../utils/id";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { Relation, ItemMap, ItemReference, MaybeDeletedItem } from "../types"; import {
Relation,
ItemMap,
ItemReference,
ValueOf,
MaybeDeletedItem
} from "../types";
import Database from "../api"; import Database from "../api";
import { SQLCollection } from "../database/sql-collection";
type RelationsArray<TType extends keyof ItemMap> = Relation[] & { import { DatabaseAccessor, DatabaseSchema } from "../database";
resolved: (limit?: number) => ItemMap[TType][]; import { SelectQueryBuilder } from "kysely";
has: (id: string) => boolean;
};
export class Relations implements ICollection { export class Relations implements ICollection {
name = "relations"; name = "relations";
readonly collection: CachedCollection<"relations", Relation>; readonly collection: SQLCollection<"relations", Relation>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "relations", db.eventManager);
db.storage,
"relations",
db.eventManager
);
} }
init() { async init() {
return this.collection.init(); // return this.collection.init();
}
async merge(relation: MaybeDeletedItem<Relation>) {
await this.collection.add(relation);
} }
async add(from: ItemReference, to: ItemReference) { async add(from: ItemReference, to: ItemReference) {
if ( await this.collection.upsert({
this.all.find(
(a) =>
compareItemReference(a.from, from) && compareItemReference(a.to, to)
)
)
return;
const relation: Relation = {
id: generateId(from, to), id: generateId(from, to),
type: "relation", type: "relation",
dateCreated: Date.now(), dateCreated: Date.now(),
dateModified: Date.now(), dateModified: Date.now(),
from: { id: from.id, type: from.type }, fromId: from.id,
to: { id: to.id, type: to.type } fromType: from.type,
}; toId: to.id,
toType: to.type
await this.collection.add(relation); });
} }
from<TType extends keyof ItemMap>( from<TType extends keyof RelatableTable>(
reference: ItemReference, reference: ItemReference,
type: TType type: TType
): RelationsArray<TType> { ) {
const relations = return new RelationsArray(
type === "note" || type === "notebook" this.db.sql,
? this.all.filter( this.db.trash.cache,
(a) => reference,
compareItemReference(a.from, reference) && type,
a.to.type === type && "from"
!this.db.trash.exists(a.to.id) );
)
: this.all.filter(
(a) => compareItemReference(a.from, reference) && a.to.type === type
);
Object.defineProperties(relations, {
resolved: {
writable: false,
enumerable: false,
configurable: false,
value: (limit?: number) =>
this.resolve(limit ? relations.slice(0, limit) : relations, "to")
},
has: {
writable: false,
enumerable: false,
configurable: false,
value: (id: string) => relations.some((rel) => rel.to.id === id)
}
});
return relations as RelationsArray<TType>;
} }
to<TType extends keyof ItemMap>( to<TType extends keyof RelatableTable>(
reference: ItemReference, reference: ItemReference,
type: TType type: TType
): RelationsArray<TType> { ) {
const relations = return new RelationsArray(
type === "note" || type === "notebook" this.db.sql,
? this.all.filter( this.db.trash.cache,
(a) => reference,
compareItemReference(a.to, reference) && type,
a.from.type === type && "to"
!this.db.trash.exists(a.from.id) );
)
: this.all.filter(
(a) => compareItemReference(a.to, reference) && a.from.type === type
);
Object.defineProperties(relations, {
resolved: {
writable: false,
enumerable: false,
configurable: false,
value: (limit?: number) =>
this.resolve(limit ? relations.slice(0, limit) : relations, "from")
},
has: {
writable: false,
enumerable: false,
configurable: false,
value: (id: string) => relations.some((rel) => rel.from.id === id)
}
});
return relations as RelationsArray<TType>;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
get all(): Relation[] { // get all(): Relation[] {
return this.collection.items(); // return this.collection.items();
} // }
relation(id: string) { // relation(id: string) {
return this.collection.get(id); // return this.collection.get(id);
} // }
async remove(...ids: string[]) { async remove(...ids: string[]) {
for (const id of ids) { await this.collection.softDelete(ids);
await this.collection.remove(id);
}
} }
async unlink(from: ItemReference, to: ItemReference) { unlink(from: ItemReference, to: ItemReference) {
const relation = this.all.find( return this.remove(generateId(from, to));
(a) =>
compareItemReference(a.from, from) && compareItemReference(a.to, to)
);
if (!relation) return;
await this.remove(relation.id);
} }
async unlinkAll(to: ItemReference, type?: keyof ItemMap) { async unlinkOfType(type: keyof RelatableTable, ids?: string[]) {
for (const relation of this.all.filter( await this.db
(a) => compareItemReference(a.to, to) && (!type || a.from.type === type) .sql()
)) { .replaceInto("relations")
await this.remove(relation.id); .columns(["id", "dateModified", "deleted"])
} .expression((eb) =>
} eb
.selectFrom("relations")
private resolve(relations: Relation[], resolveType: "from" | "to") { .where((eb) =>
const items = []; eb.or([eb("fromType", "==", type), eb("toType", "==", type)])
for (const relation of relations) { )
const reference = resolveType === "from" ? relation.from : relation.to; .$if(ids !== undefined && ids.length > 0, (eb) =>
let item = null; eb.where((eb) =>
switch (reference.type) { eb.or([eb("fromId", "in", ids), eb("toId", "in", ids)])
case "tag": )
item = this.db.tags.tag(reference.id); )
break; .select((eb) => [
case "color": "relations.id",
item = this.db.colors.color(reference.id); eb.lit(Date.now()).as("dateModified"),
break; eb.lit(1).as("deleted")
case "reminder": ])
item = this.db.reminders.reminder(reference.id); )
break; .execute();
case "note": {
const note = this.db.notes.note(reference.id);
if (!note) continue;
item = note.data;
break;
}
case "notebook": {
const notebook = this.db.notebooks.notebook(reference.id);
if (!notebook) continue;
item = notebook.data;
break;
}
case "attachment": {
const attachment = this.db.attachments.attachment(reference.id);
if (!attachment) continue;
item = attachment;
break;
}
}
if (item) items.push(item);
}
return items;
} }
} }
@@ -229,3 +145,132 @@ function generateId(a: ItemReference, b: ItemReference) {
const str = `${a.id}${b.id}${a.type}${b.type}`; const str = `${a.id}${b.id}${a.type}${b.type}`;
return makeId(str); return makeId(str);
} }
const TABLE_MAP = {
note: "notes",
notebook: "notebooks",
reminder: "reminders",
tag: "tags",
color: "colors",
attachment: "attachments"
} as const;
type RelatableTable = typeof TABLE_MAP;
class RelationsArray<TType extends keyof RelatableTable> {
private table: ValueOf<RelatableTable> = TABLE_MAP[this.type];
constructor(
private readonly sql: DatabaseAccessor,
private readonly trashIds: string[],
private readonly reference: ItemReference,
private readonly type: TType,
private readonly direction: "from" | "to"
) {}
async resolve(limit?: number): Promise<ItemMap[TType][]> {
const items = await this.sql()
.selectFrom(this.table)
.where("id", "in", (b) =>
b
.selectFrom("relations")
.$call((eb) =>
this.buildRelationsQuery()(
eb as SelectQueryBuilder<DatabaseSchema, "relations", unknown>
)
)
)
.$if(limit !== undefined && limit > 0, (b) => b.limit(limit!))
.selectAll()
// TODO: check if we need to index deleted field.
.where("deleted", "is", null)
.execute();
return items as unknown as ItemMap[TType][];
}
async unlink() {
await this.sql()
.replaceInto("relations")
.columns(["id", "dateModified", "deleted"])
.expression((eb) =>
eb
.selectFrom("relations")
.$call(this.buildRelationsQuery())
.clearSelect()
.select((eb) => [
"relations.id",
eb.lit(Date.now()).as("dateModified"),
eb.lit(1).as("deleted")
])
)
.execute();
}
async get() {
const ids = await this.sql()
.selectFrom("relations")
.$call(this.buildRelationsQuery())
.execute();
return ids.map((i) => i.id);
}
async count() {
const result = await this.sql()
.selectFrom("relations")
.$call(this.buildRelationsQuery())
.clearSelect()
.select((b) => b.fn.count<number>("relations.id").as("count"))
.executeTakeFirst();
if (!result) return 0;
return result.count;
}
async has(id: string) {
const result = await this.sql()
.selectFrom("relations")
.$call(this.buildRelationsQuery())
.clearSelect()
.where(this.direction === "from" ? "toId" : "fromId", "==", id)
.select((b) => b.fn.count<number>("id").as("count"))
.executeTakeFirst();
if (!result) return false;
return result.count > 0;
}
/**
* Build an optimized query for obtaining relations based on the given
* parameters. The resulting query uses a covering index (the most
* optimizable index) for obtaining relations.
*/
private buildRelationsQuery() {
return (
builder: SelectQueryBuilder<DatabaseSchema, "relations", unknown>
) => {
if (this.direction === "to") {
return builder
.where("fromType", "==", this.type)
.where("toType", "==", this.reference.type)
.where("toId", "==", this.reference.id)
.$if(
(this.type === "note" || this.type === "notebook") &&
this.trashIds.length > 0,
(b) => b.where("fromId", "not in", this.trashIds)
)
.select("relations.fromId as id")
.$narrowType<{ id: string }>();
} else {
return builder
.where("toType", "==", this.type)
.where("fromType", "==", this.reference.type)
.where("fromId", "==", this.reference.id)
.$if(
(this.type === "note" || this.type === "notebook") &&
this.trashIds.length > 0,
(b) => b.where("toId", "not in", this.trashIds)
)
.select("relations.toId as id")
.$narrowType<{ id: string }>();
}
};
}
}

View File

@@ -25,9 +25,9 @@ import isYesterday from "dayjs/plugin/isYesterday";
import { TimeFormat, formatDate } from "../utils/date"; import { TimeFormat, formatDate } from "../utils/date";
import { getId } from "../utils/id"; import { getId } from "../utils/id";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { CachedCollection } from "../database/cached-collection";
import { Reminder } from "../types"; import { Reminder } from "../types";
import Database from "../api"; import Database from "../api";
import { SQLCollection } from "../database/sql-collection";
dayjs.extend(isTomorrow); dayjs.extend(isTomorrow);
dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrBefore);
@@ -36,13 +36,9 @@ dayjs.extend(isToday);
export class Reminders implements ICollection { export class Reminders implements ICollection {
name = "reminders"; name = "reminders";
readonly collection: CachedCollection<"reminders", Reminder>; readonly collection: SQLCollection<"reminders", Reminder>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "reminders", db.eventManager);
db.storage,
"reminders",
db.eventManager
);
} }
async init() { async init() {
@@ -65,7 +61,7 @@ export class Reminders implements ICollection {
if (!reminder.date || !reminder.title) if (!reminder.date || !reminder.title)
throw new Error("date and title are required in a reminder."); throw new Error("date and title are required in a reminder.");
await this.collection.add({ await this.collection.upsert({
id, id,
type: "reminder", type: "reminder",
dateCreated: reminder.dateCreated || Date.now(), dateCreated: reminder.dateCreated || Date.now(),
@@ -81,16 +77,16 @@ export class Reminders implements ICollection {
disabled: reminder.disabled, disabled: reminder.disabled,
snoozeUntil: reminder.snoozeUntil snoozeUntil: reminder.snoozeUntil
}); });
return reminder.id; return id;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
get all() { // get all() {
return this.collection.items(); // return this.collection.items();
} // }
exists(itemId: string) { exists(itemId: string) {
return this.collection.exists(itemId); return this.collection.exists(itemId);
@@ -101,9 +97,7 @@ export class Reminders implements ICollection {
} }
async remove(...reminderIds: string[]) { async remove(...reminderIds: string[]) {
for (const id of reminderIds) { await this.collection.softDelete(reminderIds);
await this.collection.remove(id);
}
} }
} }

View File

@@ -22,9 +22,9 @@ import { tinyToTiptap } from "../migrations";
import { makeSessionContentId } from "../utils/id"; import { makeSessionContentId } from "../utils/id";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { isCipher } from "../database/crypto"; import { isCipher } from "../database/crypto";
import { IndexedCollection } from "../database/indexed-collection";
import Database from "../api"; import Database from "../api";
import { ContentType, SessionContentItem, isDeleted } from "../types"; import { ContentType, SessionContentItem, isDeleted } from "../types";
import { SQLCollection } from "../database/sql-collection";
export type NoteContent<TLocked extends boolean> = { export type NoteContent<TLocked extends boolean> = {
data: TLocked extends true ? Cipher<"base64"> : string; data: TLocked extends true ? Cipher<"base64"> : string;
@@ -33,13 +33,13 @@ export type NoteContent<TLocked extends boolean> = {
export class SessionContent implements ICollection { export class SessionContent implements ICollection {
name = "sessioncontent"; name = "sessioncontent";
private readonly collection: IndexedCollection< private readonly collection: SQLCollection<
"sessioncontent", "sessioncontent",
SessionContentItem SessionContentItem
>; >;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new IndexedCollection( this.collection = new SQLCollection(
db.storage, db.sql,
"sessioncontent", "sessioncontent",
db.eventManager db.eventManager
); );
@@ -49,10 +49,6 @@ export class SessionContent implements ICollection {
await this.collection.init(); await this.collection.init();
} }
async merge(item: SessionContentItem) {
await this.collection.addItem(item);
}
async add<TLocked extends boolean>( async add<TLocked extends boolean>(
sessionId: string, sessionId: string,
content: NoteContent<TLocked>, content: NoteContent<TLocked>,
@@ -64,7 +60,7 @@ export class SessionContent implements ICollection {
? content.data ? content.data
: await this.db.compressor().compress(content.data); : await this.db.compressor().compress(content.data);
await this.collection.addItem({ await this.collection.upsert({
type: "sessioncontent", type: "sessioncontent",
id: makeSessionContentId(sessionId), id: makeSessionContentId(sessionId),
data, data,
@@ -78,7 +74,7 @@ export class SessionContent implements ICollection {
} }
async get(sessionContentId: string) { async get(sessionContentId: string) {
const session = await this.collection.getItem(sessionContentId); const session = await this.collection.get(sessionContentId);
if (!session || isDeleted(session)) return; if (!session || isDeleted(session)) return;
if ( if (
@@ -93,7 +89,7 @@ export class SessionContent implements ICollection {
tinyToTiptap(await this.db.compressor().decompress(session.data)) tinyToTiptap(await this.db.compressor().decompress(session.data))
); );
session.contentType = "tiptap"; session.contentType = "tiptap";
await this.collection.addItem(session); await this.collection.upsert(session);
} }
return { return {
@@ -106,13 +102,13 @@ export class SessionContent implements ICollection {
} }
async remove(sessionContentId: string) { async remove(sessionContentId: string) {
await this.collection.deleteItem(sessionContentId); await this.collection.delete(sessionContentId);
} }
async all() { // async all() {
const indices = this.collection.indexer.indices; // const indices = this.collection.indexer.indices;
const items = await this.collection.getItems(indices); // const items = await this.collection.getItems(indices);
return Object.values(items); // return Object.values(items);
} // }
} }

View File

@@ -29,8 +29,8 @@ import {
TrashCleanupInterval TrashCleanupInterval
} from "../types"; } from "../types";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { CachedCollection } from "../database/cached-collection";
import { TimeFormat } from "../utils/date"; import { TimeFormat } from "../utils/date";
import { SQLCachedCollection } from "../database/sql-cached-collection";
const DEFAULT_GROUP_OPTIONS = (key: GroupingKey) => const DEFAULT_GROUP_OPTIONS = (key: GroupingKey) =>
({ ({
@@ -66,12 +66,12 @@ const defaultSettings: SettingItemMap = {
}; };
export class Settings implements ICollection { export class Settings implements ICollection {
name = "settingsv2"; name = "settings";
readonly collection: CachedCollection<"settingsv2", SettingItem>; readonly collection: SQLCachedCollection<"settings", SettingItem>;
constructor(db: Database) { constructor(db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCachedCollection(
db.storage, db.sql,
"settingsv2", "settings",
db.eventManager db.eventManager
); );
} }
@@ -80,19 +80,19 @@ export class Settings implements ICollection {
return this.collection.init(); return this.collection.init();
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
private async set<TKey extends keyof SettingItemMap>( private async set<TKey extends keyof SettingItemMap>(
key: TKey, key: TKey,
value: SettingItemMap[TKey] value: SettingItemMap[TKey]
) { ) {
const id = makeId(key); const id = makeId(key);
const oldItem = this.collection.get(id); const oldItem = await this.collection.get(id);
if (oldItem && oldItem.key !== key) throw new Error("Key conflict."); if (oldItem && oldItem.key !== key) throw new Error("Key conflict.");
await this.collection.add({ await this.collection.upsert({
id, id,
key, key,
value, value,

View File

@@ -18,20 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import Database from "../api"; import Database from "../api";
import { CachedCollection } from "../database/cached-collection"; import { SQLCollection } from "../database/sql-collection";
import { Notebook, Shortcut, Tag, Topic } from "../types"; import { Shortcut } from "../types";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
const ALLOWED_SHORTCUT_TYPES = ["notebook", "topic", "tag"]; const ALLOWED_SHORTCUT_TYPES = ["notebook", "topic", "tag"];
export class Shortcuts implements ICollection { export class Shortcuts implements ICollection {
name = "shortcuts"; name = "shortcuts";
readonly collection: CachedCollection<"shortcuts", Shortcut>; readonly collection: SQLCollection<"shortcuts", Shortcut>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection( this.collection = new SQLCollection(db.sql, "shortcuts", db.eventManager);
db.storage,
"shortcuts",
db.eventManager
);
} }
init() { init() {
@@ -45,11 +41,15 @@ export class Shortcuts implements ICollection {
"Please use db.shortcuts.merge to merge remote shortcuts." "Please use db.shortcuts.merge to merge remote shortcuts."
); );
if (shortcut.item && !ALLOWED_SHORTCUT_TYPES.includes(shortcut.item.type)) if (
shortcut.itemId &&
shortcut.itemType &&
!ALLOWED_SHORTCUT_TYPES.includes(shortcut.itemType)
)
throw new Error("Cannot create a shortcut for this type of item."); throw new Error("Cannot create a shortcut for this type of item.");
const oldShortcut = shortcut.item const oldShortcut = shortcut.itemId
? this.shortcut(shortcut.item.id) ? this.shortcut(shortcut.itemId)
: shortcut.id : shortcut.id
? this.shortcut(shortcut.id) ? this.shortcut(shortcut.id)
: null; : null;
@@ -64,65 +64,72 @@ export class Shortcuts implements ICollection {
const id = shortcut.id || shortcut.item.id; const id = shortcut.id || shortcut.item.id;
await this.collection.add({ await this.collection.upsert({
id, id,
type: "shortcut", type: "shortcut",
item: shortcut.item, itemId: shortcut.itemId,
itemType: shortcut.itemType,
// item: shortcut.item,
dateCreated: shortcut.dateCreated || Date.now(), dateCreated: shortcut.dateCreated || Date.now(),
dateModified: shortcut.dateModified || Date.now(), dateModified: shortcut.dateModified || Date.now(),
sortIndex: this.collection.count() sortIndex: await this.collection.count()
}); });
return id; return id;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
get all() { // get all() {
return this.collection.items(); // return this.collection.items();
} // }
get resolved() { async get() {
return this.all.reduce((prev, shortcut) => { // return this.all.reduce((prev, shortcut) => {
const { // const {
item: { id } // item: { id }
} = shortcut; // } = shortcut;
// let item: Notebook | Topic | Tag | null | undefined = null;
let item: Notebook | Topic | Tag | null | undefined = null; // switch (shortcut.item.type) {
switch (shortcut.item.type) { // case "notebook": {
case "notebook": { // const notebook = this.db.notebooks.notebook(id);
const notebook = this.db.notebooks.notebook(id); // item = notebook ? notebook.data : null;
item = notebook ? notebook.data : null; // break;
break; // }
} // case "tag":
case "tag": // item = this.db.tags.tag(id);
item = this.db.tags.tag(id); // break;
break; // }
} // if (item) prev.push(item);
if (item) prev.push(item); // return prev;
return prev; // }, [] as (Notebook | Topic | Tag)[]);
}, [] as (Notebook | Topic | Tag)[]);
} }
exists(id: string) { exists(id: string) {
return !!this.shortcut(id); return this.collection.exists(id);
} }
shortcut(id: string) { shortcut(id: string) {
return this.all.find( return this.collection.get(id);
(shortcut) => shortcut.item.id === id || shortcut.id === id
);
} }
async remove(...shortcutIds: string[]) { async remove(...shortcutIds: string[]) {
const shortcuts = this.all.filter( await this.collection.softDelete(shortcutIds);
(shortcut) => // await this.db
shortcutIds.includes(shortcut.item.id) || // .sql()
shortcutIds.includes(shortcut.id) // .deleteFrom("shortcuts")
); // .where((eb) =>
for (const { id } of shortcuts) { // eb.or([eb("id", "in", shortcutIds), eb("itemId", "in", shortcutIds)])
await this.collection.remove(id); // )
} // .execute();
// const shortcuts = this.all.filter(
// (shortcut) =>
// shortcutIds.includes(shortcut.item.id) ||
// shortcutIds.includes(shortcut.id)
// );
// for (const { id } of shortcuts) {
// await this.collection.remove(id);
// }
} }
} }

View File

@@ -18,16 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { getId } from "../utils/id"; import { getId } from "../utils/id";
import { CachedCollection } from "../database/cached-collection"; import { Tag } from "../types";
import { MaybeDeletedItem, Tag } from "../types";
import Database from "../api"; import Database from "../api";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
import { SQLCollection } from "../database/sql-collection";
export class Tags implements ICollection { export class Tags implements ICollection {
name = "tags"; name = "tags";
readonly collection: CachedCollection<"tags", Tag>; readonly collection: SQLCollection<"tags", Tag>;
constructor(private readonly db: Database) { constructor(private readonly db: Database) {
this.collection = new CachedCollection(db.storage, "tags", db.eventManager); this.collection = new SQLCollection(db.sql, "tags", db.eventManager);
} }
init() { init() {
@@ -38,58 +38,46 @@ export class Tags implements ICollection {
return this.collection.get(id); return this.collection.get(id);
} }
find(idOrTitle: string) { // find(idOrTitle: string) {
return this.all.find( // return this.all.find(
(tag) => tag.title === idOrTitle || tag.id === idOrTitle // (tag) => tag.title === idOrTitle || tag.id === idOrTitle
); // );
} // }
async merge(remoteTag: MaybeDeletedItem<Tag>) {
if (!remoteTag) return;
const localTag = this.collection.get(remoteTag.id);
if (!localTag || remoteTag.dateModified > localTag.dateModified)
await this.collection.add(remoteTag);
}
async add(item: Partial<Tag>) { async add(item: Partial<Tag>) {
if (item.remote) if (item.remote)
throw new Error("Please use db.tags.merge to merge remote tags."); throw new Error("Please use db.tags.merge to merge remote tags.");
const id = item.id || getId(item.dateCreated); const id = item.id || getId(item.dateCreated);
const oldTag = this.tag(id); const oldTag = await this.tag(id);
item.title = item.title ? Tags.sanitize(item.title) : item.title; item.title = item.title ? Tags.sanitize(item.title) : item.title;
if (!item.title && !oldTag?.title) throw new Error("Title is required."); if (!item.title && !oldTag?.title) throw new Error("Title is required.");
const tag: Tag = { await this.collection.upsert({
id, id,
dateCreated: item.dateCreated || oldTag?.dateCreated || Date.now(), dateCreated: item.dateCreated || oldTag?.dateCreated || Date.now(),
dateModified: item.dateModified || oldTag?.dateModified || Date.now(), dateModified: item.dateModified || oldTag?.dateModified || Date.now(),
title: item.title || oldTag?.title || "", title: item.title || oldTag?.title || "",
type: "tag", type: "tag",
remote: false remote: false
}; });
await this.collection.add(tag); return id;
return tag.id;
} }
get raw() { // get raw() {
return this.collection.raw(); // return this.collection.raw();
} // }
get all() { // get all() {
return this.collection.items(); // return this.collection.items();
} // }
async remove(id: string) { async remove(...ids: string[]) {
await this.collection.remove(id); await this.db.transaction(async () => {
await this.db.relations.cleanup(); await this.db.relations.unlinkOfType("tag", ids);
} await this.collection.softDelete(ids);
});
async delete(id: string) {
await this.collection.delete(id);
await this.db.relations.cleanup();
} }
exists(id: string) { exists(id: string) {

View File

@@ -19,23 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import dayjs from "dayjs"; import dayjs from "dayjs";
import Database from "../api"; import Database from "../api";
import {
BaseTrashItem,
Note,
Notebook,
TrashItem,
isTrashItem
} from "../types";
function toTrashItem<T extends Note | Notebook>(item: T): BaseTrashItem<T> {
return {
...item,
id: item.id,
type: "trash",
itemType: item.type,
dateDeleted: Date.now()
};
}
export default class Trash { export default class Trash {
collections = ["notes", "notebooks"] as const; collections = ["notes", "notebooks"] as const;
@@ -44,96 +27,122 @@ export default class Trash {
async init() { async init() {
await this.cleanup(); await this.cleanup();
this.cache = this.all.map((t) => t.id); const result = await this.db
.sql()
.selectNoFrom((eb) => [
eb
.selectFrom("notes")
.where("type", "==", "trash")
.select("id")
.as("id"),
eb
.selectFrom("notebooks")
.where("type", "==", "trash")
.select("id")
.as("id")
])
.execute();
this.cache = result.reduce((ids, item) => {
if (item.id) ids.push(item.id);
return ids;
}, [] as string[]);
} }
async cleanup() { async cleanup() {
const now = dayjs().unix();
const duration = this.db.settings.getTrashCleanupInterval(); const duration = this.db.settings.getTrashCleanupInterval();
if (duration === -1 || !duration) return; if (duration === -1 || !duration) return;
for (const item of this.all) {
if ( const maxMs = dayjs().subtract(duration, "days").toDate().getTime();
isTrashItem(item) && const expiredItems = await this.db
item.dateDeleted && .sql()
dayjs(item.dateDeleted).add(duration, "days").unix() > now .selectNoFrom((eb) => [
) eb
continue; .selectFrom("notes")
await this.delete(item.id); .where("type", "==", "trash")
} .where("dateDeleted", "<=", maxMs)
.select("id")
.as("noteId"),
eb
.selectFrom("notebooks")
.where("type", "==", "trash")
.where("dateDeleted", "<=", maxMs)
.select("id")
.as("notebookId")
])
.execute();
const { noteIds, notebookIds } = expiredItems.reduce(
(ids, item) => {
if (item.noteId) ids.noteIds.push(item.noteId);
if (item.notebookId) ids.notebookIds.push(item.notebookId);
return ids;
},
{ noteIds: [] as string[], notebookIds: [] as string[] }
);
await this.delete("note", noteIds);
await this.delete("note", notebookIds);
} }
get all(): TrashItem[] { async add(type: "note" | "notebook", ids: string[]) {
const trashItems: TrashItem[] = []; if (type === "note") {
for (const key of this.collections) { await this.db.notes.collection.update(ids, {
const collection = this.db[key]; type: "trash",
trashItems.push(...collection.trashed); itemType: "note",
dateDeleted: Date.now()
});
} else if (type === "notebook") {
await this.db.notebooks.collection.update(ids, {
type: "trash",
itemType: "notebook",
dateDeleted: Date.now()
});
} }
return trashItems; this.cache.push(...ids);
} }
private getItem(id: string) { async delete(type: "note" | "notebook", ids: string[]) {
for (const key of this.collections) { if (type === "note") {
const collection = this.db[key].collection; await this.db.content.removeByNoteId(...ids);
const item = collection.get(id); await this.db.noteHistory.clearSessions(...ids);
if (item && isTrashItem(item)) return [item, collection] as const; await this.db.notes.delete(...ids);
} else if (type === "notebook") {
await this.db.relations.unlinkOfType("notebook", ids);
await this.db.notebooks.delete(...ids);
} }
return [] as const; ids.forEach((id) => this.cache.splice(this.cache.indexOf(id), 1));
} }
async add(item: Note | Notebook) { async restore(type: "note" | "notebook", ids: string[]) {
if (item.type === "note") { if (type === "note") {
await this.db.notes.collection.update(toTrashItem(item)); await this.db.notes.collection.update(ids, {
} else if (item.type === "notebook") { type: "note",
await this.db.notebooks.collection.update(toTrashItem(item)); dateDeleted: null,
} itemType: null
this.cache.push(item.id); });
} } else {
await this.db.notebooks.collection.update(ids, {
async delete(...ids: string[]) { type: "notebook",
for (const id of ids) { dateDeleted: null,
const [item, collection] = this.getItem(id); itemType: null
if (!item || !collection) continue; });
if (item.itemType === "note") {
if (item.contentId) await this.db.content.remove(item.contentId);
await this.db.noteHistory.clearSessions(id);
} else if (item.itemType === "notebook") {
await this.db.relations.unlinkAll({ type: "notebook", id: item.id });
}
await collection.remove(id);
this.cache.splice(this.cache.indexOf(id), 1);
}
}
async restore(...ids: string[]) {
for (const id of ids) {
const [item] = this.getItem(id);
if (!item) continue;
if (item.itemType === "note") {
await this.db.notes.collection.update({ ...item, type: "note" });
} else if (item.itemType === "notebook") {
await this.db.notebooks.collection.update({
...item,
type: "notebook"
});
}
this.cache.splice(this.cache.indexOf(id), 1);
} }
ids.forEach((id) => this.cache.splice(this.cache.indexOf(id), 1));
} }
async clear() { async clear() {
for (const item of this.all) { // for (const item of this.all) {
await this.delete(item.id); // await this.delete(item.id);
} // }
this.cache = []; this.cache = [];
} }
synced(id: string) { // synced(id: string) {
const [item] = this.getItem(id); // // const [item] = this.getItem(id);
if (item && item.itemType === "note") { // if (item && item.itemType === "note") {
const { contentId } = item; // const { contentId } = item;
return !contentId || this.db.content.exists(contentId); // return !contentId || this.db.content.exists(contentId);
} else return true; // } else return true;
} // }
/** /**
* *

View File

@@ -25,13 +25,12 @@ import {
StorageAccessor StorageAccessor
} from "../interfaces"; } from "../interfaces";
import { DataFormat, SerializedKey } from "@notesnook/crypto/dist/src/types"; import { DataFormat, SerializedKey } from "@notesnook/crypto/dist/src/types";
import { AttachmentMetadata } from "../types";
import { EV, EVENTS } from "../common"; import { EV, EVENTS } from "../common";
export type FileStorageAccessor = () => FileStorage; export type FileStorageAccessor = () => FileStorage;
export type DownloadableFile = { export type DownloadableFile = {
filename: string; filename: string;
metadata: AttachmentMetadata; // metadata: AttachmentMetadata;
chunkSize: number; chunkSize: number;
}; };
export type QueueItem = DownloadableFile & { export type QueueItem = DownloadableFile & {
@@ -57,7 +56,7 @@ export class FileStorage {
this.downloads.set(groupId, files); this.downloads.set(groupId, files);
for (const file of files as QueueItem[]) { for (const file of files as QueueItem[]) {
const { filename, metadata, chunkSize } = file; const { filename, chunkSize } = file;
if (await this.exists(filename)) { if (await this.exists(filename)) {
current++; current++;
EV.publish(EVENTS.fileDownloaded, { EV.publish(EVENTS.fileDownloaded, {
@@ -71,7 +70,6 @@ export class FileStorage {
const url = `${hosts.API_HOST}/s3?name=${filename}`; const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.downloadFile(filename, { const { execute, cancel } = this.fs.downloadFile(filename, {
metadata,
url, url,
chunkSize, chunkSize,
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@@ -106,11 +104,10 @@ export class FileStorage {
this.uploads.set(groupId, files); this.uploads.set(groupId, files);
for (const file of files as QueueItem[]) { for (const file of files as QueueItem[]) {
const { filename, chunkSize, metadata } = file; const { filename, chunkSize } = file;
const url = `${hosts.API_HOST}/s3?name=${filename}`; const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.uploadFile(filename, { const { execute, cancel } = this.fs.uploadFile(filename, {
chunkSize, chunkSize,
metadata,
url, url,
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
@@ -142,21 +139,15 @@ export class FileStorage {
this.uploads.delete(groupId); this.uploads.delete(groupId);
} }
async downloadFile( async downloadFile(groupId: string, filename: string, chunkSize: number) {
groupId: string,
filename: string,
chunkSize: number,
metadata: AttachmentMetadata
) {
const url = `${hosts.API_HOST}/s3?name=${filename}`; const url = `${hosts.API_HOST}/s3?name=${filename}`;
const token = await this.tokenManager.getAccessToken(); const token = await this.tokenManager.getAccessToken();
const { execute, cancel } = this.fs.downloadFile(filename, { const { execute, cancel } = this.fs.downloadFile(filename, {
metadata,
url, url,
chunkSize, chunkSize,
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
this.downloads.set(groupId, [{ cancel, filename, chunkSize, metadata }]); this.downloads.set(groupId, [{ cancel, filename, chunkSize }]);
const result = await execute(); const result = await execute();
this.downloads.delete(groupId); this.downloads.delete(groupId);
return result; return result;

View File

@@ -0,0 +1,191 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Migrator,
Kysely,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
sql,
Driver,
KyselyPlugin,
PluginTransformQueryArgs,
PluginTransformResultArgs,
QueryResult,
UnknownRow,
RootOperationNode,
OperationNodeTransformer,
ValueNode,
PrimitiveValueListNode,
Transaction,
ColumnType
} from "kysely";
import {
Attachment,
Color,
ContentItem,
HistorySession,
Note,
Notebook,
Relation,
Reminder,
SessionContentItem,
SettingItem,
Shortcut,
Tag,
TrashOrItem,
ValueOf
} from "../types";
import { NNMigrationProvider } from "./migrations";
// type FilteredKeys<T, U> = {
// [P in keyof T]: T[P] extends U ? P : never;
// }[keyof T];
type SQLiteValue<T> = T extends string | number | boolean | Array<number>
? T
: T extends object | Array<any>
? ColumnType<T, string, string>
: never;
export type SQLiteItem<T> = {
[P in keyof T]?: T[P] | null;
} & { id: string };
export interface DatabaseSchema {
notes: SQLiteItem<TrashOrItem<Note>>; //| SQLiteItem<BaseTrashItem<Note>>;
content: SQLiteItem<ContentItem>;
relations: SQLiteItem<Relation>;
notebooks: SQLiteItem<TrashOrItem<Notebook>>;
attachments: SQLiteItem<Attachment>;
tags: SQLiteItem<Tag>;
colors: SQLiteItem<Color>;
reminders: SQLiteItem<Reminder>;
settings: SQLiteItem<SettingItem>;
notehistory: SQLiteItem<HistorySession>;
sessioncontent: SQLiteItem<SessionContentItem>;
shortcuts: SQLiteItem<Shortcut>;
}
type AsyncOrSyncResult<Async extends boolean, Response> = Async extends true
? Promise<Response>
: Response;
export interface DatabaseCollection<T, Async extends boolean> {
clear(): Promise<void>;
init(): Promise<void>;
upsert(item: T): Promise<void>;
softDelete(ids: string[]): Promise<void>;
delete(ids: string[]): Promise<void>;
exists(id: string): AsyncOrSyncResult<Async, boolean>;
count(): AsyncOrSyncResult<Async, number>;
get(id: string): AsyncOrSyncResult<Async, T | undefined>;
put(items: (T | undefined)[]): Promise<void>;
update(ids: string[], partial: Partial<T>): Promise<void>;
}
export type DatabaseAccessor = () =>
| Kysely<DatabaseSchema>
| Transaction<DatabaseSchema>;
type FilterBooleanProperties<T> = keyof {
[K in keyof T as T[K] extends boolean | undefined | null ? K : never]: T[K];
};
type BooleanFields = ValueOf<{
[D in keyof DatabaseSchema]: FilterBooleanProperties<DatabaseSchema[D]>;
}>;
const BooleanProperties: BooleanFields[] = [
"compressed",
"conflicted",
"deleted",
"disabled",
"favorite",
"localOnly",
"locked",
"migrated",
"pinned",
"readonly",
"remote",
"synced"
];
export async function createDatabase(driver: Driver) {
const db = new Kysely<DatabaseSchema>({
dialect: {
createAdapter: () => new SqliteAdapter(),
createDriver: () => driver,
createIntrospector: (db) => new SqliteIntrospector(db),
createQueryCompiler: () => new SqliteQueryCompiler()
},
plugins: [new SqliteBooleanPlugin()]
});
const migrator = new Migrator({
db,
provider: new NNMigrationProvider()
});
await sql`PRAGMA journal_mode = WAL`.execute(db);
await sql`PRAGMA synchronous = normal`.execute(db);
await migrator.migrateToLatest();
return db;
}
export class SqliteBooleanPlugin implements KyselyPlugin {
readonly #transformer = new SqliteBooleanTransformer();
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
return this.#transformer.transformNode(args.node);
}
transformResult(
args: PluginTransformResultArgs
): Promise<QueryResult<UnknownRow>> {
for (const row of args.result.rows) {
for (const key of BooleanProperties) {
const value = row[key];
row[key] = value === 1 ? true : false;
}
}
return Promise.resolve(args.result);
}
}
class SqliteBooleanTransformer extends OperationNodeTransformer {
transformValue(node: ValueNode): ValueNode {
return {
...super.transformValue(node),
value: typeof node.value === "boolean" ? (node.value ? 1 : 0) : node.value
};
}
protected transformPrimitiveValueList(
node: PrimitiveValueListNode
): PrimitiveValueListNode {
return {
...super.transformPrimitiveValueList(node),
values: node.values.map((value) =>
typeof value === "boolean" ? (value ? 1 : 0) : value
)
};
}
}

View File

@@ -0,0 +1,218 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { CreateTableBuilder, Migration, MigrationProvider, sql } from "kysely";
export class NNMigrationProvider implements MigrationProvider {
async getMigrations(): Promise<Record<string, Migration>> {
return {
"1": {
async up(db) {
await db.schema
.createTable("notes")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("title", "text")
.addColumn("headline", "text")
.addColumn("contentId", "text")
.addColumn("pinned", "boolean")
.addColumn("locked", "boolean")
.addColumn("favorite", "boolean")
.addColumn("localOnly", "boolean")
.addColumn("conflicted", "boolean")
.addColumn("readonly", "boolean")
.addColumn("dateEdited", "integer")
.addColumn("dateDeleted", "integer")
.addColumn("itemType", "text")
.addForeignKeyConstraint(
"note_has_content",
["contentId"],
"content",
["id"],
(b) => b.onDelete("restrict").onUpdate("restrict")
)
.execute();
await db.schema
.createTable("content")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("noteId", "text")
.addColumn("data", "text")
.addColumn("localOnly", "boolean")
.addColumn("conflicted", "text")
.addColumn("sessionId", "text")
.addColumn("dateEdited", "integer")
.addColumn("dateResolved", "integer")
.execute();
await db.schema
.createTable("notebooks")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("title", "text")
.addColumn("description", "text")
.addColumn("dateEdited", "text")
.addColumn("pinned", "boolean")
.execute();
await db.schema
.createTable("tags")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("title", "text")
.execute();
await db.schema
.createTable("colors")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("title", "text")
.addColumn("colorCode", "text")
.execute();
await db.schema
.createTable("relations")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("fromType", "text")
.addColumn("fromId", "text")
.addColumn("toType", "text")
.addColumn("toId", "text")
.execute();
await db.schema
.createTable("shortcuts")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("sortIndex", "integer")
.addColumn("itemId", "text")
.addColumn("itemType", "text")
.execute();
await db.schema
.createTable("reminders")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("title", "text")
.addColumn("description", "text")
.addColumn("priority", "text")
.addColumn("date", "integer")
.addColumn("mode", "text")
.addColumn("recurringMode", "text")
.addColumn("selectedDays", "blob")
.addColumn("localOnly", "boolean")
.addColumn("disabled", "boolean")
.addColumn("snoozeUntil", "integer")
.execute();
await db.schema
.createTable("attachments")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("iv", "text")
.addColumn("salt", "text")
.addColumn("size", "integer")
.addColumn("alg", "text")
.addColumn("encryptionKey", "text")
.addColumn("chunkSize", "integer")
.addColumn("hash", "text", (c) => c.unique())
.addColumn("hashType", "text")
.addColumn("mimeType", "text")
.addColumn("filename", "text")
.addColumn("dateDeleted", "integer")
.addColumn("dateUploaded", "integer")
.addColumn("failed", "text")
.execute();
await db.schema
.createIndex("relation_from_general")
.on("relations")
.columns(["fromType", "toType", "fromId"])
.where("toType", "!=", "note")
.where("toType", "!=", "notebook")
.execute();
await db.schema
.createIndex("relation_to_general")
.on("relations")
.columns(["fromType", "toType", "toId"])
.where("fromType", "!=", "note")
.where("fromType", "!=", "notebook")
.execute();
await db.schema
.createIndex("relation_from_note_notebook")
.on("relations")
.columns(["fromType", "toType", "fromId", "toId"])
.where((eb) =>
eb.or([
eb("toType", "==", "note"),
eb("toType", "==", "notebook")
])
)
.execute();
await db.schema
.createIndex("relation_to_note_notebook")
.on("relations")
.columns(["fromType", "toType", "toId", "fromId"])
.where((eb) =>
eb.or([
eb("fromType", "==", "note"),
eb("fromType", "==", "notebook")
])
)
.execute();
await db.schema
.createIndex("note_type")
.on("notes")
.columns(["type"])
.execute();
await db.schema
.createIndex("notebook_type")
.on("notebooks")
.columns(["type"])
.execute();
await db.schema
.createIndex("attachment_hash")
.on("attachments")
.column("hash")
.execute();
},
async down(db) {}
}
};
}
}
const addBaseColumns = <T extends string, C extends string = never>(
builder: CreateTableBuilder<T, C>
) => {
return builder
.addColumn("id", "text", (c) => c.primaryKey().unique().notNull())
.addColumn("type", "text")
.addColumn("dateModified", "integer")
.addColumn("dateCreated", "integer")
.addColumn("synced", "boolean")
.addColumn("deleted", "boolean");
};

View File

@@ -0,0 +1,169 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { MaybeDeletedItem, isDeleted } from "../types";
import EventManager from "../utils/event-manager";
import { DatabaseAccessor, DatabaseCollection, DatabaseSchema } from ".";
import { SQLCollection } from "./sql-collection";
export class SQLCachedCollection<
TCollectionType extends keyof DatabaseSchema,
T extends DatabaseSchema[TCollectionType] = DatabaseSchema[TCollectionType]
> implements DatabaseCollection<T, false>
{
private collection: SQLCollection<TCollectionType, T>;
private cache = new Map<string, MaybeDeletedItem<T>>();
// private cachedItems?: T[];
constructor(
sql: DatabaseAccessor,
type: TCollectionType,
eventManager: EventManager
) {
this.collection = new SQLCollection(sql, type, eventManager);
}
async init() {
await this.collection.init();
// const data = await this.collection.indexer.readMulti(
// this.collection.indexer.indices
// );
// this.cache = new Map(data);
}
// async add(item: MaybeDeletedItem<T>) {
// await this.collection.addItem(item);
// this.cache.set(item.id, item);
// this.invalidateCache();
// }
async clear() {
await this.collection.clear();
this.cache.clear();
}
async upsert(item: T) {
await this.collection.upsert(item);
this.cache.set(item.id, item);
}
async delete(ids: string[]) {
ids.forEach((id) => this.cache.delete(id));
await this.collection.delete(ids);
}
async softDelete(ids: string[]) {
ids.forEach((id) =>
this.cache.set(id, {
id,
deleted: true,
dateModified: Date.now()
})
);
await this.collection.softDelete(ids);
}
exists(id: string) {
const item = this.cache.get(id);
return !!item && !isDeleted(item);
}
count(): number {
return this.cache.size;
}
get(id: string): T | undefined {
const item = this.cache.get(id);
if (!item || isDeleted(item)) return;
return item;
}
async put(items: (T | undefined)[]): Promise<void> {
await this.collection.put(items);
for (const item of items) {
if (item) this.cache.set(item.id, item);
}
}
async update(ids: string[], partial: Partial<T>): Promise<void> {
await this.collection.update(ids, partial);
for (const id of ids) {
const item = this.cache.get(id);
if (!item) continue;
this.cache.set(id, { ...item, ...partial, dateModified: Date.now() });
}
}
// has(id: string) {
// return this.cache.has(id);
// }
// count() {
// return this.cache.size;
// }
// get(id: string) {
// const item = this.cache.get(id);
// if (!item || isDeleted(item)) return;
// return item;
// }
// getRaw(id: string) {
// const item = this.cache.get(id);
// return item;
// }
// raw() {
// return Array.from(this.cache.values());
// }
// items(map?: (item: T) => T | undefined) {
// if (this.cachedItems && this.cachedItems.length === this.cache.size)
// return this.cachedItems;
// this.cachedItems = [];
// this.cache.forEach((value) => {
// if (isDeleted(value)) return;
// const mapped = map ? map(value) : value;
// if (!mapped) return;
// this.cachedItems?.push(mapped);
// });
// this.cachedItems.sort((a, b) => b.dateCreated - a.dateCreated);
// return this.cachedItems;
// }
// async setItems(items: (MaybeDeletedItem<T> | undefined)[]) {
// await this.collection.setItems(items);
// for (const item of items) {
// if (item) {
// this.cache.set(item.id, item);
// }
// }
// this.invalidateCache();
// }
// *iterateSync(chunkSize: number) {
// yield* chunkedIterate(Array.from(this.cache.values()), chunkSize);
// }
// invalidateCache() {
// this.cachedItems = undefined;
// }
}

View File

@@ -0,0 +1,148 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { EVENTS } from "../common";
import { isDeleted } from "../types";
import EventManager from "../utils/event-manager";
import {
DatabaseAccessor,
DatabaseCollection,
DatabaseSchema,
SQLiteItem
} from ".";
export class SQLCollection<
TCollectionType extends keyof DatabaseSchema,
T extends DatabaseSchema[TCollectionType] = DatabaseSchema[TCollectionType]
> implements DatabaseCollection<SQLiteItem<T>, true>
{
constructor(
private readonly db: DatabaseAccessor,
private readonly type: TCollectionType,
private readonly eventManager: EventManager
) {}
async clear() {
await this.db().deleteFrom(this.type).execute();
}
async init() {}
async upsert(item: SQLiteItem<T>) {
if (!item.id) throw new Error("The item must contain the id field.");
if (!item.deleted) item.dateCreated = item.dateCreated || Date.now();
this.eventManager.publish(EVENTS.databaseUpdated, item.id, item);
// if item is newly synced, remote will be true.
if (!item.remote) {
item.dateModified = Date.now();
item.synced = false;
}
// the item has become local now, so remove the flags
delete item.remote;
await this.db()
.replaceInto<keyof DatabaseSchema>(this.type)
.values(item)
.execute();
}
async softDelete(ids: string[]) {
this.eventManager.publish(EVENTS.databaseUpdated, ids);
await this.db()
.replaceInto<keyof DatabaseSchema>(this.type)
.values(
ids.map((id) => ({
id,
deleted: true,
dateModified: Date.now()
}))
)
.execute();
}
async delete(ids: string[]) {
this.eventManager.publish(EVENTS.databaseUpdated, ids);
await this.db()
.deleteFrom<keyof DatabaseSchema>(this.type)
.where("id", "in", ids)
.execute();
}
async exists(id: string) {
const { count } =
(await this.db()
.selectFrom<keyof DatabaseSchema>(this.type)
.select((a) => a.fn.count<number>("id").as("count"))
.where("id", "==", id)
.limit(1)
.executeTakeFirst()) || {};
return count !== undefined && count > 0;
}
async count() {
const { count } =
(await this.db()
.selectFrom<keyof DatabaseSchema>(this.type)
.select((a) => a.fn.count<number>("id").as("count"))
.where("deleted", "is", null)
.executeTakeFirst()) || {};
return count || 0;
}
async get(id: string) {
const item = await this.db()
.selectFrom<keyof DatabaseSchema>(this.type)
.selectAll()
.where("id", "==", id)
.executeTakeFirst();
if (!item || isDeleted(item)) return;
return item as T;
}
async put(items: (SQLiteItem<T> | undefined)[]) {
const entries = items.reduce((array, item) => {
if (!item) return array;
if (!item.remote) {
item.dateModified = Date.now();
item.synced = false;
}
delete item.remote;
array.push(item);
return array;
}, [] as SQLiteItem<T>[]);
await this.db()
.replaceInto<keyof DatabaseSchema>(this.type)
.values(entries)
.execute();
}
async update(ids: string[], partial: Partial<SQLiteItem<T>>) {
await this.db()
.updateTable<keyof DatabaseSchema>(this.type)
.where("id", "in", ids)
.set({
...partial,
dateModified: Date.now()
})
.execute();
}
}

View File

@@ -18,14 +18,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Cipher, DataFormat, SerializedKey } from "@notesnook/crypto"; import { Cipher, DataFormat, SerializedKey } from "@notesnook/crypto";
import { AttachmentMetadata } from "./types";
export type Output<TOutputFormat extends DataFormat> = export type Output<TOutputFormat extends DataFormat> =
TOutputFormat extends Omit<DataFormat, "uint8array"> ? string : Uint8Array; TOutputFormat extends Omit<DataFormat, "uint8array"> ? string : Uint8Array;
export type FileEncryptionMetadata = { export type FileEncryptionMetadata = {
chunkSize: number; chunkSize: number;
iv: string; iv: string;
length: number; size: number;
salt: string; salt: string;
alg: string; alg: string;
}; };
@@ -74,7 +73,7 @@ export interface ICompressor {
export type RequestOptions = { export type RequestOptions = {
url: string; url: string;
metadata?: AttachmentMetadata; // metadata?: AttachmentMetadata;
chunkSize: number; chunkSize: number;
headers: { Authorization: string }; headers: { Authorization: string };
}; };

View File

@@ -298,10 +298,17 @@ const migrations: Migration[] = [
return true; return true;
}, },
shortcut: (item) => { shortcut: (item) => {
if (item.item.type === "topic") { if (item.item?.type === "topic") {
item.item = { type: "notebook", id: item.item.id }; item.item = { type: "notebook", id: item.item.id };
return true;
} }
if (item.item) {
item.itemId = item.item.id;
item.itemType = item.item.type;
}
delete item.item;
return true;
}, },
settings: async (item, db) => { settings: async (item, db) => {
if (item.trashCleanupInterval) if (item.trashCleanupInterval)
@@ -336,6 +343,16 @@ const migrations: Migration[] = [
} }
} }
return true; return true;
},
relation: (item) => {
item.fromId = item.from!.id;
item.fromType = item.from!.type;
item.toId = item.to!.id;
item.toType = item.to!.type;
delete item.to;
delete item.from;
return true;
} }
} }
}, },

View File

@@ -131,6 +131,7 @@ export interface BaseItem<TType extends ItemType> {
migrated?: boolean; migrated?: boolean;
remote?: boolean; remote?: boolean;
synced?: boolean; synced?: boolean;
deleted?: boolean;
} }
export type NotebookReference = { export type NotebookReference = {
@@ -164,6 +165,9 @@ export interface Note extends BaseItem<"note"> {
readonly: boolean; readonly: boolean;
dateEdited: number; dateEdited: number;
dateDeleted: null;
itemType: null;
} }
export interface Notebook extends BaseItem<"notebook"> { export interface Notebook extends BaseItem<"notebook"> {
@@ -171,6 +175,10 @@ export interface Notebook extends BaseItem<"notebook"> {
description?: string; description?: string;
dateEdited: number; dateEdited: number;
pinned: boolean; pinned: boolean;
dateDeleted: null;
itemType: null;
/** /**
* @deprecated only kept here for migration purposes. * @deprecated only kept here for migration purposes.
*/ */
@@ -191,6 +199,9 @@ export interface Topic extends BaseItem<"topic"> {
notes?: string[]; notes?: string[];
} }
/**
* @deprecated only kept here for migration purposes
*/
export type AttachmentMetadata = { export type AttachmentMetadata = {
hash: string; hash: string;
hashType: string; hashType: string;
@@ -201,15 +212,32 @@ export type AttachmentMetadata = {
export interface Attachment extends BaseItem<"attachment"> { export interface Attachment extends BaseItem<"attachment"> {
iv: string; iv: string;
salt: string; salt: string;
length: number;
alg: string; alg: string;
key: Cipher<"base64">;
chunkSize: number; chunkSize: number;
metadata: AttachmentMetadata;
dateUploaded?: number; dateUploaded?: number;
failed?: string; failed?: string;
dateDeleted?: number; dateDeleted?: number;
filename: string;
size: number;
hash: string;
hashType: string;
mimeType: string;
encryptionKey: string;
/**
* @deprecated only kept here for migration purposes
*/
key?: Cipher<"base64">;
/**
* @deprecated only kept here for migration purposes
*/
length?: number;
/**
* @deprecated only kept here for migration purposes
*/
metadata?: AttachmentMetadata;
/** /**
* @deprecated only kept here for migration purposes * @deprecated only kept here for migration purposes
*/ */
@@ -244,14 +272,31 @@ export type ItemReference = {
}; };
export interface Relation extends BaseItem<"relation"> { export interface Relation extends BaseItem<"relation"> {
from: ItemReference; fromId: string;
to: ItemReference; fromType: keyof ItemMap;
toId: string;
toType: keyof ItemMap;
/**
* @deprecated only kept here for migration purposes
*/
from?: ItemReference;
/**
* @deprecated only kept here for migration purposes
*/
to?: ItemReference;
} }
/**
* @deprecated only kept here for migration purposes
*/
type BaseShortcutReference = { type BaseShortcutReference = {
id: string; id: string;
}; };
/**
* @deprecated only kept here for migration purposes
*/
type TagNotebookShortcutReference = BaseShortcutReference & { type TagNotebookShortcutReference = BaseShortcutReference & {
type: "tag" | "notebook"; type: "tag" | "notebook";
}; };
@@ -265,7 +310,14 @@ type TopicShortcutReference = BaseShortcutReference & {
}; };
export interface Shortcut extends BaseItem<"shortcut"> { export interface Shortcut extends BaseItem<"shortcut"> {
item: TopicShortcutReference | TagNotebookShortcutReference; itemId: string;
itemType: "tag" | "notebook";
/**
* @deprecated only kept here for migration purposes
*/
item?: TopicShortcutReference | TagNotebookShortcutReference;
sortIndex: number; sortIndex: number;
} }
@@ -376,17 +428,14 @@ export type TrashOrItem<T extends BaseItem<"note" | "notebook">> =
export type BaseTrashItem<TItem extends BaseItem<"note" | "notebook">> = export type BaseTrashItem<TItem extends BaseItem<"note" | "notebook">> =
BaseItem<"trash"> & { BaseItem<"trash"> & {
title: string;
itemType: TItem["type"]; itemType: TItem["type"];
dateDeleted: number; dateDeleted: number;
} & Omit<TItem, "id" | "type">; } & Omit<TItem, "id" | "type" | "dateDeleted" | "itemType">;
export type TrashItem = BaseTrashItem<Note> | BaseTrashItem<Notebook>; export type TrashItem = BaseTrashItem<Note> | BaseTrashItem<Notebook>;
export function isDeleted<T extends BaseItem<ItemType>>( export function isDeleted(item: object): item is DeletedItem {
item: MaybeDeletedItem<T> return "deleted" in item && !!item.deleted;
): item is DeletedItem {
return "deleted" in item;
} }
export function isTrashItem(item: MaybeDeletedItem<Item>): item is TrashItem { export function isTrashItem(item: MaybeDeletedItem<Item>): item is TrashItem {

View File

@@ -27,10 +27,14 @@ export default defineConfig({
coverage: { coverage: {
reporter: ["text", "html"] reporter: ["text", "html"]
}, },
exclude: ["__benches__/**/*.bench.ts"],
include: [ include: [
...(IS_E2E ? ["__e2e__/**/*.test.{js,ts}"] : []), ...(IS_E2E ? ["__e2e__/**/*.test.{js,ts}"] : []),
"__tests__/**/*.test.{js,ts}", "__tests__/**/*.test.{js,ts}",
"src/**/*.test.{js,ts}" "src/**/*.test.{js,ts}"
] ],
benchmark: {
include: ["__benches__/**/*.bench.ts"]
}
} }
}); });