Compare commits

...

2 Commits

Author SHA1 Message Date
Abdullah Atta
0f620242f8 core: more migrations 2023-04-06 01:05:29 +05:00
Abdullah Atta
e21e826175 core: first batch of migrations 2023-04-04 15:38:46 +05:00
100 changed files with 3352 additions and 1636 deletions

View File

@@ -17,10 +17,10 @@ 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 DB from "../../api";
import DB from "../../src/api";
import StorageInterface from "../../__mocks__/storage.mock";
import dayjs from "dayjs";
import { groupArray } from "../../utils/grouping";
import { groupArray } from "../../src/utils/grouping";
import FS from "../../__mocks__/fs.mock";
import Compressor from "../../__mocks__/compressor.mock";
@@ -37,7 +37,7 @@ const TEST_NOTEBOOK2 = {
};
function databaseTest() {
let db = new DB(StorageInterface, null, FS, Compressor);
const db = new DB(StorageInterface, null, FS, Compressor);
return db.init().then(() => db);
}
@@ -47,7 +47,7 @@ const notebookTest = (notebook = TEST_NOTEBOOK) =>
return { db, id };
});
var TEST_NOTE = {
const TEST_NOTE = {
content: {
type: "tiptap",
data: `<p>Hello<br/><span style="color:#f00">This is colorful</span></p>`

View File

@@ -1,75 +0,0 @@
/*
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 { EV, EVENTS } from "../common";
import CachedCollection from "../database/cached-collection";
import IndexedCollection from "../database/indexed-collection";
class Collection {
static async new(db, name, cached = true, deferred = false) {
const collection = new this(db, name, cached);
if (!deferred) await collection.init();
else await collection._collection.indexer.init();
if (collection._collection.clear)
EV.subscribe(
EVENTS.userLoggedOut,
async () => await collection._collection.clear()
);
return collection;
}
async init() {
if (this.initialized) return;
await this._collection.init();
EV.publish(EVENTS.databaseCollectionInitiated, this.collectionName);
this.initialized = true;
}
/**
*
* @param {import("../api").default} db
*/
constructor(db, name, cached) {
this._db = db;
this.collectionName = name;
if (cached)
this._collection = new CachedCollection(
this._db.storage,
name,
this._db.eventManager
);
else
this._collection = new IndexedCollection(
this._db.storage,
name,
this._db.eventManager
);
}
async encrypted() {
const data = await this._collection.indexer.readMulti(
this._collection.indexer.getIndices()
);
return data.map((d) => d[1]);
}
}
export default Collection;

View File

@@ -1,109 +0,0 @@
/*
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 IndexedCollection from "./indexed-collection";
import MapStub from "../utils/map";
export default class CachedCollection extends IndexedCollection {
constructor(context, type, eventManager) {
super(context, type, eventManager);
this.type = type;
this.map = new Map();
this.items = undefined;
// this.eventManager = eventManager;
// this.encryptionKeyFactory = encryptionKeyFactory;
}
async init() {
await super.init();
let data = await this.indexer.readMulti(this.indexer.indices);
if (this.map && this.map.dispose) this.map.dispose();
// const encryptionKey =
// this.encryptionKeyFactory && (await this.encryptionKeyFactory());
// if (encryptionKey) {
// for (let item of data) {
// const [_key, value] = item;
// const decryptedValue = JSON.parse(
// await this.indexer.decrypt(encryptionKey, value)
// );
// item[1] = decryptedValue;
// }
// }
this.map = new MapStub.Map(data, this.type);
this.invalidateCache();
}
async clear() {
await super.clear();
this.map.clear();
this.invalidateCache();
}
async updateItem(item) {
await super.updateItem(item);
this.map.set(item.id, item);
this.invalidateCache();
}
exists(id) {
const item = this.getItem(id);
return item && !item.deleted;
}
has(id) {
return this.map.has(id);
}
count() {
return this.map.size;
}
getItem(id) {
return this.map.get(id);
}
async deleteItem(id) {
this.map.delete(id);
await super.deleteItem(id);
this.invalidateCache();
}
getRaw() {
return Array.from(this.map.values());
}
getItems(map = undefined) {
if (this.items && this.items.length === this.map.size) return this.items;
this.items = [];
this.map.forEach((value) => {
if (!value || value.deleted || !value.id) return;
value = map ? map(value) : value;
this.items.push(value);
});
this.items.sort((a, b) => b.dateCreated - a.dateCreated);
return this.items;
}
invalidateCache() {
this.items = undefined;
}
}

View File

@@ -1,80 +0,0 @@
/*
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 { randomBytes } from "../utils/random";
export default class Storage {
constructor(storage) {
this.storage = storage;
}
write(key, data) {
return this.storage.write(key, data);
}
readMulti(keys) {
return this.storage.readMulti(keys);
}
read(key, isArray = false) {
return this.storage.read(key, isArray);
}
clear() {
return this.storage.clear();
}
remove(key) {
return this.storage.remove(key);
}
getAllKeys() {
return this.storage.getAllKeys();
}
encrypt(password, data) {
return this.storage.encrypt(password, data);
}
decrypt(password, cipher) {
return this.storage.decrypt(password, cipher);
}
deriveCryptoKey(name, data) {
return this.storage.deriveCryptoKey(name, data);
}
hash(password, userId) {
return this.storage.hash(password, userId);
}
getCryptoKey(name) {
return this.storage.getCryptoKey(name);
}
generateCryptoKey(password, salt) {
return this.storage.generateCryptoKey(password, salt);
}
async generateRandomKey() {
const passwordBytes = randomBytes(124);
const password = passwordBytes.toString("base64");
return await this.storage.generateCryptoKey(password);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "@notesnook/core",
"version": "7.3.6",
"main": "./api/index.js",
"main": "./dist/api/index.js",
"types": "./dist/api/index.d.ts",
"license": "GPL-3.0-or-later",
"repository": {
"type": "git",
@@ -14,7 +15,9 @@
"@babel/runtime": "^7.18.9",
"@notesnook/crypto": "^1.0.1",
"@types/jest": "^28.1.6",
"@types/mime-db": "^1.43.1",
"@types/showdown": "^2.0.0",
"@types/spark-md5": "^3.0.2",
"abortcontroller-polyfill": "^1.7.3",
"analyze-es6-modules": "^0.6.2",
"babel-jest": "^28.1.3",
@@ -28,9 +31,11 @@
"jest-fetch-mock": "^3.0.3",
"jsdom": "^20.0.0",
"mockdate": "^3.0.5",
"otplib": "^12.0.1"
"otplib": "^12.0.1",
"vitest": "^0.29.8"
},
"scripts": {
"build": "tsc",
"test:e2e": "env-cmd -e e2e jest --forceExit",
"test": "jest --forceExit"
},
@@ -46,9 +51,10 @@
"htmlparser2": "^8.0.1",
"linkedom": "^0.14.17",
"liqe": "^1.13.0",
"mime-db": "1.52.0",
"qclone": "^1.2.0",
"spark-md5": "^3.0.2",
"mime-db": "1.52.0"
"rfdc": "^1.3.0",
"spark-md5": "^3.0.2"
},
"overrides": {
"htmlparser2": "^8.0.1"

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Debug from "../debug";
import { noteTest, notebookTest, databaseTest } from "../../__tests__/utils";
import { noteTest, notebookTest, databaseTest } from "../../../__tests__/utils";
import { enableFetchMocks, disableFetchMocks } from "jest-fetch-mock";
test("strip empty item shouldn't throw", () => {

View File

@@ -17,10 +17,11 @@ 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 { Item } from "../entities";
import hosts from "../utils/constants";
export default class Debug {
strip(item) {
export class Debug {
static strip(item: Item) {
if (!item) return "{}";
return JSON.stringify({
title: !!item.title,
@@ -42,18 +43,11 @@ export default class Debug {
});
}
/**
*
* @param {{
* title: string,
* body: string,
* userId: string
* }} reportData
* @returns {Promise<string>} link to the github issue
*/
async report(reportData) {
if (!reportData) return;
static async report(reportData: {
title: string;
body: string;
userId: string;
}): Promise<string | undefined> {
const { title, body, userId } = reportData;
const response = await fetch(`${hosts.ISSUES_HOST}/create/notesnook`, {
method: "POST",

View File

@@ -18,17 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Notes from "../collections/notes";
import Storage from "../database/storage";
import FileStorage from "../database/fs";
import Notebooks from "../collections/notebooks";
import Trash from "../collections/trash";
import Tags from "../collections/tags";
import { Tags } from "../collections/tags";
import Sync from "./sync";
import Vault from "./vault";
import Lookup from "./lookup";
import { Lookup } from "./lookup";
import Content from "../collections/content";
import Backup from "../database/backup";
import Session from "./session";
import { SystemClock } from "./system-clock";
import Constants from "../utils/constants";
import { EV, EVENTS } from "../common";
import Settings from "./settings";
@@ -37,65 +36,63 @@ import Outbox from "./outbox";
import UserManager from "./user-manager";
import http from "../utils/http";
import Monographs from "./monographs";
import Offers from "./offers";
import Attachments from "../collections/attachments";
import Debug from "./debug";
import { Mutex } from "async-mutex";
import NoteHistory from "../collections/note-history";
import MFAManager from "./mfa-manager";
import EventManager from "../utils/event-manager";
import Pricing from "./pricing";
import { logger } from "../logger";
import Shortcuts from "../collections/shortcuts";
import Reminders from "../collections/reminders";
import Relations from "../collections/relations";
import Subscriptions from "./subscriptions";
import { Relations } from "../collections/relations";
import { Subscriptions } from "./subscriptions";
import { ICompressor, IFileStorage, IStorage } from "../interfaces";
import { TokenManager } from "./token-manager";
/**
* @type {EventSource}
*/
var NNEventSource;
// const DIFFERENCE_THRESHOLD = 20 * 1000;
// const MAX_TIME_ERROR_FAILURES = 5;
type Options = {
storage: IStorage;
eventsource: new (
uri: string,
init: EventSourceInit & { headers?: Record<string, string> }
) => EventSource;
fs: IFileStorage;
compressor: ICompressor;
};
class Database {
/**
*
* @param {any} storage
* @param {EventSource} eventsource
*/
constructor(storage, eventsource, fs, compressor) {
/**
* @type {EventSource}
*/
this.evtSource = null;
this.sseMutex = new Mutex();
this.lastHeartbeat = undefined; // { local: 0, server: 0 };
this.timeErrorFailures = 0;
this.eventManager = new EventManager();
this.compressor = compressor;
private readonly sseMutex = new Mutex();
private sseConnection: EventSource | null = null;
private readonly fileStorage: FileStorage;
private readonly tokenManager: TokenManager;
this.storage = new Storage(storage);
this.fs = new FileStorage(fs, storage);
NNEventSource = eventsource;
}
readonly notes: Notes;
readonly relations: Relations;
readonly tags: Tags;
readonly eventManager = new EventManager();
readonly subscriptions: Subscriptions;
async _validate() {
if (!(await this.session.valid())) {
throw new Error(
"Your system clock is not setup correctly. Please adjust your date and time and then retry."
);
}
await this.session.set();
constructor(private readonly options: Options) {
this.notes = new Notes(options.storage);
this.relations = new Relations(options.storage);
this.tags = new Tags(options.storage);
this.fileStorage = new FileStorage(options.fs, options.storage);
this.tokenManager = new TokenManager(options.storage);
this.subscriptions = new Subscriptions(this.tokenManager);
}
async init() {
await this.isSystemClockValid();
EV.subscribeMulti(
[EVENTS.userLoggedIn, EVENTS.userFetched, EVENTS.tokenRefreshed],
this.connectSSE,
this
);
EV.subscribe(EVENTS.attachmentDeleted, async (attachment) => {
await this.fs.cancel(attachment.metadata.hash);
await this.fileStorage.cancel(attachment.metadata.hash);
});
EV.subscribe(EVENTS.userLoggedOut, async () => {
await this.monographs.deinit();
@@ -113,9 +110,6 @@ class Database {
}
});
this.session = new Session(this.storage);
await this._validate();
this.user = new UserManager(this.storage, this);
this.mfa = new MFAManager(this.storage, this);
this.syncer = new Sync(this);
@@ -126,14 +120,8 @@ class Database {
this.migrations = new Migrations(this);
this.outbox = new Outbox(this);
this.monographs = new Monographs(this);
this.offers = new Offers();
this.debug = new Debug();
this.pricing = new Pricing();
this.subscriptions = new Subscriptions(this.user.tokenManager);
// collections
/** @type {Notes} */
this.notes = await Notes.new(this, "notes", true, true);
/** @type {Notebooks} */
this.notebooks = await Notebooks.new(this, "notebooks");
/** @type {Tags} */
@@ -166,29 +154,23 @@ class Database {
}
disconnectSSE() {
if (!this.evtSource) return;
this.evtSource.onopen = null;
this.evtSource.onmessage = null;
this.evtSource.onerror = null;
this.evtSource.close();
this.evtSource = null;
if (!this.sseConnection) return;
this.sseConnection.onopen = null;
this.sseConnection.onmessage = null;
this.sseConnection.onerror = null;
this.sseConnection.close();
this.sseConnection = null;
}
/**
*
* @param {{force: boolean, error: any}} args
*/
async connectSSE(args) {
if (args && !!args.error) return;
async connectSSE(args?: { force: boolean }) {
await this.sseMutex.runExclusive(async () => {
this.eventManager.publish(EVENTS.databaseSyncRequested, true, false);
const forceReconnect = args && args.force;
if (
!NNEventSource ||
(!forceReconnect &&
this.evtSource &&
this.evtSource.readyState === this.evtSource.OPEN)
!forceReconnect &&
this.sseConnection &&
this.sseConnection.readyState === this.sseConnection.OPEN
)
return;
this.disconnectSSE();
@@ -196,92 +178,95 @@ class Database {
const token = await this.user.tokenManager.getAccessToken();
if (!token) return;
this.evtSource = new NNEventSource(`${Constants.SSE_HOST}/sse`, {
headers: { Authorization: `Bearer ${token}` }
});
this.sseConnection = new this.options.eventsource(
`${Constants.SSE_HOST}/sse`,
{
headers: { Authorization: `Bearer ${token}` }
}
);
this.evtSource.onopen = async () => {
this.sseConnection.onopen = async () => {
console.log("SSE: opened channel successfully!");
};
this.evtSource.onerror = function (error) {
this.sseConnection.onerror = function (error) {
console.log("SSE: error:", error);
};
this.evtSource.onmessage = async (event) => {
this.sseConnection.onmessage = async (event) => {
try {
var { type, data } = JSON.parse(event.data);
data = JSON.parse(data);
const message = JSON.parse(event.data);
const data = JSON.parse(message.data);
switch (message.type) {
// TODO: increase reliablity for this.
// case "heartbeat": {
// const { t: serverTime } = data;
// const localTime = Date.now();
// if (!this.lastHeartbeat) {
// this.lastHeartbeat = { local: localTime, server: serverTime };
// break;
// }
// const timeElapsed = {
// local: localTime - this.lastHeartbeat.local,
// server: serverTime - this.lastHeartbeat.server,
// };
// const travelTime = timeElapsed.local - timeElapsed.server;
// const actualTime = localTime - travelTime;
// const diff = actualTime - serverTime;
// // Fail several times consecutively before raising an error. This is done to root out
// // false positives.
// if (Math.abs(diff) > DIFFERENCE_THRESHOLD) {
// if (this.timeErrorFailures >= MAX_TIME_ERROR_FAILURES) {
// EV.publish(EVENTS.systemTimeInvalid, { serverTime, localTime });
// } else this.timeErrorFailures++;
// } else this.timeErrorFailures = 0;
// this.lastHeartbeat.local = localTime;
// this.lastHeartbeat.server = serverTime;
// break;
// }
case "upgrade": {
const user = await this.user.getUser();
user.subscription = data;
await this.user.setUser(user);
EV.publish(EVENTS.userSubscriptionUpdated, data);
break;
}
case "userDeleted": {
await this.user.logout(false, "Account Deleted");
break;
}
case "userEmailChanged": {
await this.user.logout(true, "Email changed");
break;
}
case "userPasswordChanged": {
await this.user.logout(true, "Password changed");
break;
}
case "emailConfirmed": {
const token = await this.storage.read("token");
await this.user.tokenManager._refreshToken(token);
await this.user.fetchUser(true);
EV.publish(EVENTS.userEmailConfirmed);
break;
}
}
} catch (e) {
console.log("SSE: Unsupported message. Message = ", event.data);
return;
}
switch (type) {
// TODO: increase reliablity for this.
// case "heartbeat": {
// const { t: serverTime } = data;
// const localTime = Date.now();
// if (!this.lastHeartbeat) {
// this.lastHeartbeat = { local: localTime, server: serverTime };
// break;
// }
// const timeElapsed = {
// local: localTime - this.lastHeartbeat.local,
// server: serverTime - this.lastHeartbeat.server,
// };
// const travelTime = timeElapsed.local - timeElapsed.server;
// const actualTime = localTime - travelTime;
// const diff = actualTime - serverTime;
// // Fail several times consecutively before raising an error. This is done to root out
// // false positives.
// if (Math.abs(diff) > DIFFERENCE_THRESHOLD) {
// if (this.timeErrorFailures >= MAX_TIME_ERROR_FAILURES) {
// EV.publish(EVENTS.systemTimeInvalid, { serverTime, localTime });
// } else this.timeErrorFailures++;
// } else this.timeErrorFailures = 0;
// this.lastHeartbeat.local = localTime;
// this.lastHeartbeat.server = serverTime;
// break;
// }
case "upgrade": {
const user = await this.user.getUser();
user.subscription = data;
await this.user.setUser(user);
EV.publish(EVENTS.userSubscriptionUpdated, data);
break;
}
case "userDeleted": {
await this.user.logout(false, "Account Deleted");
break;
}
case "userEmailChanged": {
await this.user.logout(true, "Email changed");
break;
}
case "userPasswordChanged": {
await this.user.logout(true, "Password changed");
break;
}
case "emailConfirmed": {
const token = await this.storage.read("token");
await this.user.tokenManager._refreshToken(token);
await this.user.fetchUser(true);
EV.publish(EVENTS.userEmailConfirmed);
break;
}
}
};
});
}
async lastSynced() {
return (await this.storage.read("lastSynced")) || 0;
return (await this.options.storage.read<number>("lastSynced")) || 0;
}
sync(full = true, force = false) {
@@ -313,6 +298,16 @@ class Database {
if (user) url += `?userId=${user.id}`;
return http.get(url);
}
private async isSystemClockValid() {
const systemClock = new SystemClock(this.options.storage);
if (!(await systemClock.valid())) {
throw new Error(
"Your system clock is not setup correctly. Please adjust your date and time and then retry."
);
}
await systemClock.set();
}
}
export default Database;

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { filter, parse } from "liqe";
export default class Lookup {
export class Lookup {
/**
*
* @param {import('./index').default} db

View File

@@ -33,7 +33,7 @@ class MFAManager {
/**
*
* @param {import("../database/storage").default} storage
* @param {import("../api/index").default} db
* @param {import("./index").default} db
*/
constructor(storage, db) {
this._storage = storage;

View File

@@ -21,8 +21,8 @@ import { CLIENT_ID } from "../common";
import hosts from "../utils/constants";
import http from "../utils/http";
export default class Offers {
async getCode(promo, platform) {
export class Offers {
static async getCode(promo: string, platform: "ios" | "android" | "web") {
const result = await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/offers?promoCode=${promo}&clientId=${CLIENT_ID}&platformId=${platform}`
);

View File

@@ -19,35 +19,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "../utils/http";
type Product = {
country: string;
countryCode: string;
sku?: string;
price?: string;
discount: number;
};
const BASE_URL = `https://notesnook.com/api/v1/prices`;
class Pricing {
/**
*
* @param {"android"|"ios"|"web"} platform
* @param {"monthly"|"yearly"} period
* @returns {Promise<{
* country: string,
* countryCode: string,
* sku: string,
* discount: number
* }>}
*/
sku(platform, period) {
export class Pricing {
static sku(
platform: "android" | "ios" | "web",
period: "monthly" | "yearly"
): Promise<Product> {
return http.get(`${BASE_URL}/skus/${platform}/${period}`);
}
/**
*
* @param {"monthly"|"yearly"} period
* @returns {Promise<{
* country: string,
* countryCode: string,
* price: string,
* discount: number
* }>}
*/
price(period = "monthly") {
static price(period: "monthly" | "yearly" = "monthly"): Promise<Product> {
return http.get(`${BASE_URL}/${period}`);
}
}
export default Pricing;

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { EV, EVENTS } from "../common";
import id from "../utils/id";
import "../types";
import "../../types";
class Settings {
/**

View File

@@ -19,23 +19,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import hosts from "../utils/constants";
import http from "../utils/http";
import { ITokenManager } from "./token-manager";
export default class Subscriptions {
/**
* @param {import("../api/token-manager").default} tokenManager
*/
constructor(tokenManager) {
this._tokenManager = tokenManager;
}
export class Subscriptions {
constructor(private readonly tokenManager: ITokenManager) {}
async cancel() {
const token = this._tokenManager.getAccessToken();
const token = this.tokenManager.getAccessToken();
if (!token) return;
await http.delete(`${hosts.SUBSCRIPTIONS_HOST}/subscriptions`, token);
}
async updateUrl() {
const token = this._tokenManager.getAccessToken();
const token = this.tokenManager.getAccessToken();
if (!token) return;
return await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/update_url`,

View File

@@ -22,7 +22,7 @@ import {
TEST_NOTE,
delay,
StorageInterface
} from "../../../__tests__/utils";
} from "../../../../__tests__/utils";
import Collector from "../collector";
beforeEach(async () => {

View File

@@ -18,12 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Database from "../../index";
import { NodeStorageInterface } from "../../../__mocks__/node-storage.mock";
import FS from "../../../__mocks__/fs.mock";
import Compressor from "../../../__mocks__/compressor.mock";
import { NodeStorageInterface } from "../../../../__mocks__/node-storage.mock";
import FS from "../../../../__mocks__/fs.mock";
import Compressor from "../../../../__mocks__/compressor.mock";
import { CHECK_IDS, EV, EVENTS } from "../../../common";
import EventSource from "eventsource";
import { delay } from "../../../__tests__/utils";
import { delay } from "../../../../__tests__/utils";
jest.setTimeout(100 * 1000);

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { EVENTS } from "../../common";
import { logger } from "../../logger";
import { logger } from "../../../logger";
export class AutoSync {
/**

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { CURRENT_DATABASE_VERSION } from "../../common";
import { logger } from "../../logger";
import { logger } from "../../../logger";
class Collector {
/**

View File

@@ -34,7 +34,7 @@ import Conflicts from "./conflicts";
import { AutoSync } from "./auto-sync";
import { toChunks } from "../../utils/array";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { logger } from "../../logger";
import { logger } from "../../../logger";
import { Mutex } from "async-mutex";
/**

View File

@@ -17,9 +17,9 @@ 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 { migrateItem } from "../../migrations";
import { migrateItem } from "../../../migrations";
import setManipulator from "../../utils/set";
import { logger } from "../../logger";
import { logger } from "../../../logger";
import { isHTMLEqual } from "../../utils/html-diff";
class Merger {

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import set from "../../utils/set";
import qclone from "qclone";
import { logger } from "../../logger";
import { logger } from "../../../logger";
export class SyncQueue {
/**

View File

@@ -17,21 +17,17 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class Session {
/**
*
* @param {import("../database/storage").default} context
*/
constructor(context) {
this._storage = context;
}
import { IStorage } from "../interfaces";
export class SystemClock {
constructor(private readonly storage: IStorage) {}
get() {
return this._storage.read("t");
return this.storage.read<number>("t");
}
set() {
return this._storage.write("t", Date.now());
return this.storage.write("t", Date.now());
}
async valid() {
@@ -39,4 +35,3 @@ class Session {
return !t || t < Date.now();
}
}
export default Session;

View File

@@ -22,6 +22,16 @@ import constants from "../utils/constants";
import { EV, EVENTS } from "../common";
import { withTimeout, Mutex } from "async-mutex";
import { logger } from "../logger";
import { IStorage } from "../interfaces";
import { ILogger } from "@notesnook/logger";
type AccessToken = {
access_token: string;
scope: string;
refresh_token: string;
t: number;
expires_in: number;
};
const ENDPOINTS = {
token: "/connect/token",
@@ -30,41 +40,45 @@ const ENDPOINTS = {
logout: "/account/logout"
};
class TokenManager {
/**
*
* @param {import("../database/storage").default} storage
*/
constructor(storage) {
this._storage = storage;
this._refreshTokenMutex = withTimeout(new Mutex(), 10 * 1000);
export interface ITokenManager {
getAccessToken(forceRenew?: boolean): Promise<string | undefined>;
}
export class TokenManager implements ITokenManager {
private readonly refreshTokenMutex = withTimeout(new Mutex(), 10 * 1000);
private readonly logger: ILogger;
constructor(private readonly storage: IStorage) {
this.logger = logger.scope("TokenManager");
}
async getToken(renew = true, forceRenew = false) {
let token = await this._storage.read("token");
async getToken(
renew = true,
forceRenew = false
): Promise<AccessToken | undefined> {
const token = await this.storage.read<AccessToken>("token");
if (!token || !token.access_token) return;
this.logger.info("Access token requested", {
accessToken: token.access_token.slice(0, 10)
});
const isExpired = renew && this._isTokenExpired(token);
if (this._isTokenRefreshable(token) && (forceRenew || isExpired)) {
await this._refreshToken(forceRenew);
const isExpired = renew && this.isTokenExpired(token);
if (this.isTokenRefreshable(token) && (forceRenew || isExpired)) {
await this.refreshToken(forceRenew);
return await this.getToken(false, false);
}
return token;
}
_isTokenExpired(token) {
private isTokenExpired(token: AccessToken) {
const { t, expires_in } = token;
const expiryMs = t + expires_in * 1000;
return Date.now() >= expiryMs;
}
_isTokenRefreshable(token) {
private isTokenRefreshable(token: AccessToken) {
const { scope, refresh_token } = token;
if (!refresh_token || !scope) return false;
@@ -80,12 +94,12 @@ class TokenManager {
}, "Error getting access token:");
}
async _refreshToken(forceRenew = false) {
await this._refreshTokenMutex.runExclusive(async () => {
private async refreshToken(forceRenew = false) {
await this.refreshTokenMutex.runExclusive(async () => {
this.logger.info("Refreshing access token");
const token = await this.getToken(false, false);
if (!forceRenew && !this._isTokenExpired(token)) {
if (!token || (!forceRenew && !this.isTokenExpired(token))) {
return;
}
@@ -120,13 +134,13 @@ class TokenManager {
);
}
saveToken(tokenResponse) {
private saveToken(tokenResponse: AccessToken) {
if (!tokenResponse) return;
let token = { ...tokenResponse, t: Date.now() };
return this._storage.write("token", token);
const token = { ...tokenResponse, t: Date.now() };
return this.storage.write("token", token);
}
async getAccessTokenFromAuthorizationCode(userId, authCode) {
async getAccessTokenFromAuthorizationCode(userId: string, authCode: string) {
return await this.saveToken(
await http.post(`${constants.AUTH_HOST}${ENDPOINTS.temporaryToken}`, {
authorization_code: authCode,
@@ -136,14 +150,16 @@ class TokenManager {
);
}
}
export default TokenManager;
async function getSafeToken(action, errorMessage) {
async function getSafeToken<T>(action: () => Promise<T>, errorMessage: string) {
try {
return await action();
} catch (e) {
console.error(errorMessage, e);
if (e.message === "invalid_grant" || e.message === "invalid_client") {
if (
e instanceof Error &&
(e.message === "invalid_grant" || e.message === "invalid_client")
) {
EV.publish(EVENTS.userSessionExpired);
}
throw e;

View File

@@ -17,7 +17,7 @@ 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 "../types";
import "../../types";
import http from "../utils/http";
import constants from "../utils/constants";
import TokenManager from "./token-manager";
@@ -41,7 +41,7 @@ class UserManager {
/**
*
* @param {import("../database/storage").default} storage
* @param {import("../api/index").default} db
* @param {import("./index").default} db
*/
constructor(storage, db) {
this._storage = storage;

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common";
import { tinyToTiptap } from "../migrations";
import { tinyToTiptap } from "../../migrations";
const ERASE_TIME = 1000 * 60 * 30;
var ERASER_TIMEOUT = null;

View File

@@ -0,0 +1,81 @@
/*
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 CachedCollection from "../database/cached-collection";
import { BaseItem, Collections, CollectionType } from "../entities";
import { IStorage } from "../interfaces";
export interface ICollection<
TCollectionType extends CollectionType,
T extends BaseItem<Collections[TCollectionType]>
> {
merge(item: T): Promise<void>;
// add(item: Partial<T>): Promise<string | undefined>;
// delete(...ids: string[]): Promise<void>;
}
abstract class Collection<
TCollectionType extends CollectionType,
T extends BaseItem<Collections[TCollectionType]>
> {
protected collection: CachedCollection<TCollectionType, T>;
// static async new(db, name, cached = true, deferred = false) {
// const collection = new this(db, name, cached);
// if (!deferred) await collection.init();
// else await collection._collection.indexer.init();
// if (collection._collection.clear)
// EV.subscribe(
// EVENTS.userLoggedOut,
// async () => await collection._collection.clear()
// );
// return collection;
// }
// async init() {
// if (this.initialized) return;
// await this._collection.init();
// EV.publish(EVENTS.databaseCollectionInitiated, this.collectionName);
// this.initialized = true;
// }
constructor(storage: IStorage, name: TCollectionType) {
this.collection = new CachedCollection(storage, name);
// this._db = db;
// this.collectionName = name;
// if (cached)
// this._collection = new CachedCollection(
// this._db.storage,
// name,
// this._db.eventManager
// );
// else
// this._collection = new IndexedCollection(
// this._db.storage,
// name,
// this._db.eventManager
// );
}
abstract merge(item: T): Promise<void>;
}
export default Collection;

View File

@@ -17,61 +17,72 @@ 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 Collection from "./collection";
import Note from "../models/note";
import { ICollection } from "./collection";
// import Note from "../models/note";
import {
MaybeDeletedItem,
Note,
TrashOrItem,
isDeleted,
isTrashItem
} from "../entities";
import getId from "../utils/id";
import { getContentFromData } from "../content-types";
import qclone from "qclone";
import { deleteItem, findById } from "../utils/array";
import CachedCollection from "../database/cached-collection";
import { IStorage } from "../interfaces";
import { clone } from "../utils/clone";
/**
* @typedef {{ id: string, topic?: string, rebuildCache?: boolean }} NotebookReference
*/
type NotebookReference = {
id: string;
topic?: string;
rebuildCache?: boolean;
};
export default class Notes extends Collection {
constructor(db, name, cached) {
super(db, name, cached);
this.topicReferences = new NoteIdCache(this._db);
export default class Notes implements ICollection<"notes", Note> {
private readonly collection: CachedCollection<"notes", TrashOrItem<Note>>;
private readonly topicReferences: NoteIdCache;
constructor(storage: IStorage) {
this.collection = new CachedCollection(storage, "notes");
this.topicReferences = new NoteIdCache(this);
}
async init() {
await super.init();
await this.collection.init();
this.topicReferences.rebuild();
}
trashed(id) {
return this.raw.find((item) => item.dateDeleted > 0 && item.id === id);
}
async merge(remoteNote) {
async merge(remoteNote: MaybeDeletedItem<TrashOrItem<Note>>) {
if (!remoteNote) return;
if (isDeleted(remoteNote))
return await this.collection.removeItem(remoteNote.id);
const id = remoteNote.id;
const localNote = this._collection.getItem(id);
const localNote = this.collection.getItem(id);
if (localNote) {
if (localNote.color) await this._db.colors.untag(localNote.color, id);
for (let tag of localNote.tags || []) {
for (const tag of localNote.tags || []) {
await this._db.tags.untag(tag, id);
}
}
if (remoteNote.deleted) return await this._collection.addItem(remoteNote);
if (isTrashItem(remoteNote))
return await this.collection.addItem(remoteNote);
await this._resolveColorAndTags(remoteNote);
await this.resolveColorAndTags(remoteNote);
return await this._collection.addItem(remoteNote);
return await this.collection.addItem(remoteNote);
}
async add(noteArg) {
if (!noteArg) return;
async add(noteArg: Partial<Note>) {
if (noteArg.remote)
throw new Error("Please use db.notes.merge to merge remote notes.");
let id = noteArg.id || getId();
let oldNote = this._collection.getItem(id);
const id = noteArg.id || getId();
const oldNote = this.collection.getItem(id);
let note = {
let note: Note = {
...oldNote,
...noteArg
};
@@ -134,34 +145,25 @@ export default class Notes extends Collection {
dateModified: note.dateModified
};
await this._collection.addItem(note);
await this.collection.addItem(note);
await this._resolveColorAndTags(note);
await this.resolveColorAndTags(note);
return note.id;
}
/**
*
* @param {string} id The id of note
* @returns {Note} The note of the given id
*/
note(id) {
note(id: string | TrashOrItem<Note>) {
if (!id) return;
let note = id.type ? id : this._collection.getItem(id);
if (!note || note.deleted) return;
const note = typeof id === "string" ? this.collection.getItem(id) : id;
if (!note || isTrashItem(note)) return;
return new Note(note, this._db);
}
get raw() {
return this._collection.getRaw();
return this.collection.getRaw();
}
/**
* @returns {any[]}
*/
get all() {
const items = this._collection.getItems();
return items;
return this.collection.getItems();
}
get pinned() {
@@ -176,30 +178,31 @@ export default class Notes extends Collection {
return this.all.filter((item) => item.favorite === true);
}
get deleted() {
return this.raw.filter((item) => item.dateDeleted > 0);
get trashed() {
return this.all.filter((item) => isTrashItem(item));
}
get locked() {
return this.all.filter((item) => item.locked === true);
}
tagged(tagId) {
return this._getTagItems(tagId, "tags");
isTrashed(id: string) {
return this.all.find((item) => item.id === id && isTrashItem(item));
}
colored(colorId) {
return this._getTagItems(colorId, "colors");
tagged(tagId: string) {
return this.getTagItems(tagId, "tags");
}
exists(id) {
return this._collection.exists(id);
colored(colorId: string) {
return this.getTagItems(colorId, "colors");
}
/**
* @private
*/
_getTagItems(tagId, collection) {
exists(id: string) {
return this.collection.exists(id);
}
private getTagItems(tagId: string, collection) {
const tag = this._db[collection].tag(tagId);
if (!tag || tag.noteIds.length <= 0) return [];
const array = tag.noteIds.reduce((arr, id) => {
@@ -210,26 +213,23 @@ export default class Notes extends Collection {
return array.sort((a, b) => b.dateCreated - a.dateCreated);
}
delete(...ids) {
return this._delete(true, ...ids);
delete(...ids: string[]) {
return this.deleteOrRemove(true, ...ids);
}
remove(...ids) {
return this._delete(false, ...ids);
remove(...ids: string[]) {
return this.deleteOrRemove(false, ...ids);
}
/**
* @private
*/
async _delete(moveToTrash = true, ...ids) {
for (let id of ids) {
let item = this.note(id);
private async deleteOrRemove(moveToTrash = true, ...ids: string[]) {
for (const id of ids) {
const item = this.note(id);
if (!item) continue;
const itemData = qclone(item.data);
const itemData = clone(item.data);
if (itemData.notebooks && !moveToTrash) {
for (let notebook of itemData.notebooks) {
for (let topicId of notebook.topics) {
for (const notebook of itemData.notebooks) {
for (const topicId of notebook.topics) {
await this.removeFromNotebook(
{ id: notebook.id, topic: topicId, rebuildCache: false },
id
@@ -238,7 +238,7 @@ export default class Notes extends Collection {
}
}
for (let tag of itemData.tags) {
for (const tag of itemData.tags) {
await this._db.tags.untag(tag, id);
}
@@ -247,24 +247,24 @@ export default class Notes extends Collection {
}
const attachments = this._db.attachments.ofNote(itemData.id, "all");
for (let attachment of attachments) {
for (const attachment of attachments) {
await this._db.attachments.delete(
attachment.metadata.hash,
itemData.id
);
}
// await this._collection.removeItem(id);
// await this.collection.removeItem(id);
if (moveToTrash) await this._db.trash.add(itemData);
else {
await this._collection.removeItem(id);
await this.collection.removeItem(id);
await this._db.content.remove(itemData.contentId);
}
}
this.topicReferences.rebuild();
}
async _resolveColorAndTags(note) {
private async resolveColorAndTags(note: Note) {
const { color, tags, id } = note;
if (color) {
@@ -285,17 +285,14 @@ export default class Notes extends Collection {
}
}
/**
* @param {NotebookReference} to
*/
async addToNotebook(to, ...noteIds) {
private async addToNotebook(to: NotebookReference, ...noteIds: string[]) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id) throw new Error("The destination notebook must contain id.");
const { id: notebookId, topic: topicId } = to;
for (let noteId of noteIds) {
let note = this._db.notes.note(noteId);
for (const noteId of noteIds) {
const note = this.note(noteId);
if (!note || note.data.deleted) continue;
if (topicId) {
@@ -316,7 +313,7 @@ export default class Notes extends Collection {
}
if (!noteHasNotebook || !noteHasTopic) {
await this._db.notes.add({
await this.add({
id: noteId,
notebooks
});
@@ -331,10 +328,7 @@ export default class Notes extends Collection {
}
}
/**
* @param {NotebookReference} to
*/
async removeFromNotebook(to, ...noteIds) {
async removeFromNotebook(to: NotebookReference, ...noteIds: string[]) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id) throw new Error("The destination notebook must contain id.");
@@ -357,7 +351,7 @@ export default class Notes extends Collection {
if (topics.length <= 0) deleteItem(notebooks, notebook);
await this._db.notes.add({
await this.add({
id: noteId,
notebooks
});
@@ -371,14 +365,14 @@ export default class Notes extends Collection {
if (rebuildCache) this.topicReferences.rebuild();
}
async removeFromAllNotebooks(...noteIds) {
async removeFromAllNotebooks(...noteIds: string[]) {
for (const noteId of noteIds) {
const note = this.note(noteId);
if (!note || note.deleted) {
continue;
}
await this._db.notes.add({
await this.add({
id: noteId,
notebooks: []
});
@@ -387,30 +381,30 @@ export default class Notes extends Collection {
this.topicReferences.rebuild();
}
async _clearAllNotebookReferences(notebookId) {
const notes = this._db.notes.all;
async _clearAllNotebookReferences(notebookId: string) {
const notes = this.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) continue;
for (let notebook of notebooks) {
for (const notebook of notebooks) {
if (notebook.id !== notebookId) continue;
if (!deleteItem(notebooks, notebook)) continue;
}
await this._collection.updateItem(note);
await this.collection.updateItem(note);
}
}
}
function getNoteHeadline(note, content) {
function getNoteHeadline(note: Note, content) {
if (note.locked) return "";
return content.toHeadline();
}
const NEWLINE_STRIP_REGEX = /[\r\n\t\v]+/gm;
function getNoteTitle(note, oldNote) {
function getNoteTitle(note: Note, oldNote?: Note) {
if (note.title && note.title.trim().length > 0) {
return note.title.replace(NEWLINE_STRIP_REGEX, " ");
} else if (oldNote && oldNote.title && oldNote.title.trim().length > 0) {
@@ -424,32 +418,28 @@ function getNoteTitle(note, oldNote) {
}
class NoteIdCache {
/**
*
* @param {import("../api/index").default} db
*/
constructor(db) {
this._db = db;
private cache: Map<string, string[]>;
constructor(private readonly notes: Notes) {
this.cache = new Map();
}
rebuild() {
this.cache = new Map();
const notes = this._db.notes.all;
this.cache = new Map<string, string[]>();
const notes = this.notes.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) continue;
for (let notebook of notebooks) {
for (let topic of notebook.topics) {
for (const notebook of notebooks) {
for (const topic of notebook.topics) {
this.add(topic, note.id);
}
}
}
}
add(topicId, noteId) {
add(topicId: string, noteId: string) {
let noteIds = this.cache.get(topicId);
if (!noteIds) noteIds = [];
if (noteIds.includes(noteId)) return;
@@ -457,19 +447,19 @@ class NoteIdCache {
this.cache.set(topicId, noteIds);
}
has(topicId, noteId) {
let noteIds = this.cache.get(topicId);
has(topicId: string, noteId: string) {
const noteIds = this.cache.get(topicId);
if (!noteIds) return false;
return noteIds.includes(noteId);
}
count(topicId) {
let noteIds = this.cache.get(topicId);
count(topicId: string) {
const noteIds = this.cache.get(topicId);
if (!noteIds) return 0;
return noteIds.length;
}
get(topicId) {
get(topicId: string) {
return this.cache.get(topicId) || [];
}
}

View File

@@ -17,37 +17,24 @@ 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 CachedCollection from "../database/cached-collection";
import { ItemReference, ItemType, Relation } from "../entities";
import { IStorage } from "../interfaces";
import { makeId } from "../utils/id";
import Collection from "./collection";
import { ICollection } from "./collection";
/**
* @typedef {{
* id: string;
* type: string;
* }} ItemReference
*
* @typedef {{
* id: string;
* type: string;
* from: ItemReference;
* to: ItemReference;
* dateCreated: number;
* dateModified: number;
* }} Relation
*/
export class Relations implements ICollection<"relations", Relation> {
private readonly collection: CachedCollection<"relations", Relation>;
export default class Relations extends Collection {
async merge(relation) {
if (!relation) return;
await this._collection.addItem(relation);
constructor(storage: IStorage) {
this.collection = new CachedCollection(storage, "relations");
}
/**
*
* @param {ItemReference} from
* @param {ItemReference} to
*/
async add(from, to) {
async merge(relation: Relation) {
await this.collection.addItem(relation);
}
async add(from: ItemReference, to: ItemReference) {
if (
this.all.find(
(a) =>
@@ -56,7 +43,7 @@ export default class Relations extends Collection {
)
return;
const relation = {
const relation: Relation = {
id: generateId(from, to),
type: "relation",
dateCreated: Date.now(),
@@ -65,27 +52,17 @@ export default class Relations extends Collection {
to: { id: to.id, type: to.type }
};
await this._collection.addItem(relation);
await this.collection.addItem(relation);
}
/**
*
* @param {ItemReference} reference
* @param {string} type
*/
from(reference, type) {
from(reference: ItemReference, type: ItemType) {
const relations = this.all.filter(
(a) => compareItemReference(a.from, reference) && a.to.type === type
);
return this.resolve(relations, "to");
}
/**
*
* @param {ItemReference} reference
* @param {string} type
*/
to(reference, type) {
to(reference: ItemReference, type: ItemType) {
const relations = this.all.filter(
(a) => compareItemReference(a.to, reference) && a.from.type === type
);
@@ -94,45 +71,32 @@ export default class Relations extends Collection {
/**
* Count number of from -> to relations
* @param {ItemReference} reference
* @param {string} type
*/
count(reference, type) {
count(reference: ItemReference, type: ItemType) {
return this.all.filter(
(a) => compareItemReference(a.from, reference) && a.to.type === type
).length;
}
get raw() {
return this._collection.getRaw();
return this.collection.getRaw();
}
/**
* @return {Relation[]}
*/
get all() {
return this._collection.getItems();
return this.collection.getItems();
}
/**
* @return {Relation}
*/
relation(id) {
return this._collection.getItem(id);
relation(id: string) {
return this.collection.getItem(id);
}
async remove(...ids) {
async remove(...ids: string[]) {
for (const id of ids) {
await this._collection.removeItem(id);
await this.collection.removeItem(id);
}
}
/**
*
* @param {ItemReference} from
* @param {ItemReference} to
*/
async unlink(from, to) {
async unlink(from: ItemReference, to: ItemReference) {
const relation = this.all.find(
(a) =>
compareItemReference(a.from, from) && compareItemReference(a.to, to)
@@ -142,12 +106,7 @@ export default class Relations extends Collection {
await this.remove(relation.id);
}
/**
*
* @param {ItemReference} from
* @param {string} type
*/
async unlinkAll(to, type) {
async unlinkAll(to: ItemReference, type: ItemType) {
for (const relation of this.all.filter(
(a) => compareItemReference(a.to, to) && a.from.type === type
)) {
@@ -155,14 +114,7 @@ export default class Relations extends Collection {
}
}
/**
* @param {Relation[]} relations
* @param {"from" | "to"} resolveType
* @private
*
* @returns {Relation[]}
*/
resolve(relations, resolveType) {
private resolve(relations: Relation[], resolveType: "from" | "to") {
const items = [];
for (const relation of relations) {
const reference = resolveType === "from" ? relation.from : relation.to;
@@ -190,10 +142,10 @@ export default class Relations extends Collection {
}
async cleanup() {
const relations = this._collection.getItems();
const relations = this.collection.getItems();
for (const relation of relations) {
const references = [relation.to, relation.from];
for (let reference of references) {
for (const reference of references) {
let exists = false;
switch (reference.type) {
case "reminder":
@@ -216,21 +168,14 @@ export default class Relations extends Collection {
}
}
/**
*
* @param {ItemReference} a
* @param {ItemReference} b
*/
function compareItemReference(a, b) {
function compareItemReference(a: ItemReference, b: ItemReference) {
return a.id === b.id && a.type === b.type;
}
/**
* Generate deterministic constant id from `a` & `b` item reference.
* @param {ItemReference} a
* @param {ItemReference} b
* Generate a deterministic constant id from `a` & `b` item reference.
*/
function generateId(a, b) {
function generateId(a: ItemReference, b: ItemReference) {
const str = `${a.id}${b.id}${a.type}${b.type}`;
return makeId(str);
}

View File

@@ -17,30 +17,34 @@ 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 Collection from "./collection";
import { ICollection } from "./collection";
import { makeId } from "../utils/id";
import { deleteItems, hasItem } from "../utils/array";
import setManipulator from "../utils/set";
import { set } from "../utils/set";
import { Mutex } from "async-mutex";
import { Tag } from "../entities";
import CachedCollection from "../database/cached-collection";
import { IStorage } from "../interfaces";
export default class Tags extends Collection {
constructor(db, name, cached) {
super(db, name, cached);
export class Tags implements ICollection<"tags", Tag> {
private readonly collection: CachedCollection<"tags", Tag>;
private readonly mutex: Mutex;
constructor(storage: IStorage) {
this.collection = new CachedCollection(storage, "tags");
this.mutex = new Mutex();
}
tag(id) {
tag(id: string) {
const tagItem = this.all.find((t) => t.id === id || t.title === id);
return tagItem;
}
async merge(tag) {
async merge(tag: Tag) {
if (!tag) return;
await this._collection.addItem(tag);
await this.collection.addItem(tag);
}
async add(tagId, ...noteIds) {
async add(tagId: string, ...noteIds: string[]) {
return this.mutex.runExclusive(async () => {
tagId = this.sanitize(tagId);
if (!tagId) throw new Error("Tag title cannot be empty.");
@@ -61,33 +65,32 @@ export default class Tags extends Collection {
type: "tag",
id,
title: tag.title,
noteIds: setManipulator.union(notes, noteIds),
noteIds: set.union(notes, noteIds),
localOnly: true
};
await this._collection.addItem(tag);
await this.collection.addItem(tag);
if (!this._db.settings.getAlias(tag.id))
await this._db.settings.setAlias(tag.id, tag.title);
return tag;
});
}
async rename(tagId, newName) {
let tag = this.tag(tagId);
async rename(tagId: string, newName: string) {
const tag = this.tag(tagId);
if (!tag) {
console.error(`No tag found. Tag id:`, tagId);
return;
}
newName = this.sanitize(newName);
if (!newName) throw new Error("Tag title cannot be empty.");
await this._db.settings.setAlias(tagId, newName);
await this._collection.addItem({ ...tag, alias: newName });
await this.collection.addItem({ ...tag, alias: newName });
}
alias(tagId) {
let tag = this.tag(tagId);
alias(tagId: string) {
const tag = this.tag(tagId);
if (!tag) {
console.error(`No tag found. Tag id:`, tagId);
return;
@@ -97,38 +100,35 @@ export default class Tags extends Collection {
}
get raw() {
return this._collection.getRaw();
return this.collection.getRaw();
}
/**
* @return {any[]}
*/
get all() {
return this._collection.getItems((item) => {
return this.collection.getItems((item) => {
item.alias = this._db.settings.getAlias(item.id) || item.title;
return item;
});
}
async remove(tagId) {
let tag = this.tag(tagId);
async remove(tagId: string) {
const tag = this.tag(tagId);
if (!tag) {
console.error(`No tag found. Tag id:`, tagId);
return;
}
for (let noteId of tag.noteIds) {
for (const noteId of tag.noteIds) {
const note = this._db.notes.note(noteId);
if (!note) continue;
if (hasItem(note.tags, tag.title)) await note.untag(tag.title);
}
await this._db.shortcuts.remove(tagId);
await this._collection.deleteItem(tagId);
await this.collection.deleteItem(tagId);
}
async untag(tagId, ...noteIds) {
let tag = this.tag(tagId);
async untag(tagId: string, ...noteIds: string[]) {
const tag = this.tag(tagId);
if (!tag) {
console.error(`No such tag found. Tag title:`, tagId);
return;
@@ -136,15 +136,14 @@ export default class Tags extends Collection {
deleteItems(tag.noteIds, ...noteIds);
if (tag.noteIds.length > 0) await this._collection.addItem(tag);
if (tag.noteIds.length > 0) await this.collection.addItem(tag);
else {
await this._db.shortcuts.remove(tag.id);
await this._collection.deleteItem(tag.id);
await this.collection.deleteItem(tag.id);
}
}
sanitize(tag) {
if (!tag) return;
sanitize(tag: string) {
let sanitized = tag.toLocaleLowerCase();
sanitized = sanitized.replace(/[\s]+/g, "");
// sanitized = sanitized.replace(/[+!@#$%^&*()+{}\][:;'"<>?/.\s=,]+/g, "");

View File

@@ -0,0 +1,115 @@
/*
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 IndexedCollection from "./indexed-collection";
import { MapStub } from "../utils/map";
import {
BaseItem,
Collections,
CollectionType,
isDeleted,
MaybeDeletedItem
} from "../entities";
import { IStorage } from "../interfaces";
export default class CachedCollection<
TCollectionType extends CollectionType,
T extends BaseItem<Collections[TCollectionType]>
> extends IndexedCollection<TCollectionType, T> {
private cache = new Map<string, MaybeDeletedItem<T>>();
private items?: T[];
constructor(storage: IStorage, type: TCollectionType) {
super(storage, type);
}
async init() {
await super.init();
const data = await this.indexer.readMulti(this.indexer.getIndices());
if ("dispose" in this.cache && typeof this.cache.dispose === "function")
this.cache.dispose();
this.cache = new MapStub.Map(data);
this.resetCache();
}
async clear() {
await super.clear();
this.cache.clear();
this.resetCache();
}
async updateItem(item: T) {
await super.updateItem(item);
this.cache.set(item.id, item);
this.resetCache();
}
async deleteItem(id: string) {
this.cache.delete(id);
await super.deleteItem(id);
this.resetCache();
}
async removeItem(id: string) {
this.cache.set(id, { id, deleted: true });
await super.removeItem(id);
this.resetCache();
}
exists(id: string) {
const item = this.cache.get(id);
return super.exists(id) && !!item && !isDeleted(item);
}
has(id: string) {
return this.cache.has(id);
}
count() {
return this.cache.size;
}
getItem(id: string) {
const item = this.cache.get(id);
if (!item || isDeleted(item)) return;
return item;
}
getRaw() {
return Array.from(this.cache.values());
}
getItems(manipulate?: (item: T) => T) {
if (this.items && this.items.length === this.cache.size) return this.items;
this.items = [];
this.cache.forEach((value) => {
if (isDeleted(value)) return;
value = manipulate ? manipulate(value) : value;
this.items?.push(value);
});
this.items.sort((a, b) => b.dateCreated - a.dateCreated);
return this.items;
}
resetCache() {
this.items = undefined;
}
}

View File

@@ -19,69 +19,82 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import hosts from "../utils/constants";
import TokenManager from "../api/token-manager";
import { IFileStorage, IStorage } from "../interfaces";
import { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
type QueueEntry = {
groupId: string;
filename: string;
cancel: (reason?: string) => Promise<void>;
type: "download" | "upload";
};
export default class FileStorage {
constructor(fs, storage) {
this.fs = fs;
private readonly tokenManager: TokenManager;
private readonly queue: QueueEntry[];
constructor(private readonly fs: IFileStorage, storage: IStorage) {
this.tokenManager = new TokenManager(storage);
this._queue = [];
this.queue = [];
}
async downloadFile(groupId, filename, chunkSize, metadata) {
async downloadFile(groupId: string, filename: string, chunkSize: number) {
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const token = await this.tokenManager.getAccessToken();
const { execute, cancel } = this.fs.downloadFile(filename, {
metadata,
url,
chunkSize,
headers: { Authorization: `Bearer ${token}` }
});
this._queue.push({ groupId, filename, cancel, type: "download" });
this.queue.push({ groupId, filename, cancel, type: "download" });
const result = await execute();
this._deleteOp(groupId, "download");
this.deleteOp(groupId, "download");
return result;
}
async uploadFile(groupId, filename) {
async uploadFile(groupId: string, filename: string) {
const token = await this.tokenManager.getAccessToken();
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.uploadFile(filename, {
url,
headers: { Authorization: `Bearer ${token}` }
});
this._queue.push({ groupId, filename, cancel, type: "upload" });
this.queue.push({ groupId, filename, cancel, type: "upload" });
const result = await execute();
this._deleteOp(groupId, "upload");
this.deleteOp(groupId, "upload");
return result;
}
async cancel(groupId, type = undefined) {
const [op] = this._deleteOp(groupId, type);
async cancel(groupId: string, type = undefined) {
const [op] = this.deleteOp(groupId, type);
if (!op) return;
await op.cancel("Operation canceled.");
}
_deleteOp(groupId, type = undefined) {
const opIndex = this._queue.findIndex(
private deleteOp(groupId: string, type?: QueueEntry["type"]) {
const opIndex = this.queue.findIndex(
(item) => item.groupId === groupId && (!type || item.type === type)
);
if (opIndex < 0) return [];
return this._queue.splice(opIndex, 1);
return this.queue.splice(opIndex, 1);
}
readEncrypted(filename, encryptionKey, cipherData) {
readEncrypted(
filename: string,
encryptionKey: SerializedKey,
cipherData: Cipher
) {
return this.fs.readEncrypted(filename, encryptionKey, cipherData);
}
writeEncryptedBase64(data, encryptionKey, mimeType) {
return this.fs.writeEncryptedBase64({
data,
key: encryptionKey,
mimeType
});
writeEncryptedBase64(
data: string,
encryptionKey: SerializedKey,
mimeType: string
) {
return this.fs.writeEncryptedBase64(data, encryptionKey, mimeType);
}
async deleteFile(filename, localOnly) {
async deleteFile(filename: string, localOnly = false) {
if (localOnly) return await this.fs.deleteFile(filename);
const token = await this.tokenManager.getAccessToken();
@@ -92,28 +105,15 @@ export default class FileStorage {
});
}
/**
*
* @param {string} filename
* @returns {Promise<boolean>}
*/
exists(filename) {
exists(filename: string) {
return this.fs.exists(filename);
}
/**
*
* @returns {Promise<void>}
*/
clear() {
return this.fs.clearFileStorage();
}
/**
* @param {string} data
* @returns {Promise<{hash: string, type: string}>}
*/
hashBase64(data) {
hashBase64(data: string) {
return this.fs.hashBase64(data);
}
}

View File

@@ -18,13 +18,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { EVENTS } from "../common";
import { BaseItem, Collections, CollectionType } from "../entities";
import { IStorage } from "../interfaces";
import Indexer from "./indexer";
export default class IndexedCollection {
constructor(context, type, eventManager) {
this.indexer = new Indexer(context, type);
this.eventManager = eventManager;
// this.encryptionKeyFactory = encryptionKeyFactory;
export default class IndexedCollection<
TCollectionType extends CollectionType,
T extends BaseItem<Collections[TCollectionType]>
> {
/**
* @internal
*/
readonly indexer: Indexer<T>;
constructor(
protected readonly storage: IStorage,
type: TCollectionType
// eventManager
) {
this.indexer = new Indexer(storage, type);
// this.eventManager = eventManager;
}
clear() {
@@ -35,7 +47,7 @@ export default class IndexedCollection {
await this.indexer.init();
}
async addItem(item) {
async addItem(item: T) {
if (!item.id) throw new Error("The item must contain the id field.");
const exists = this.exists(item.id);
@@ -46,9 +58,9 @@ export default class IndexedCollection {
}
}
async updateItem(item) {
async updateItem(item: T) {
if (!item.id) throw new Error("The item must contain the id field.");
this.eventManager.publish(EVENTS.databaseUpdated, item.id, item);
this.notify(item.id, item);
// if item is newly synced, remote will be true.
if (!item.remote) {
@@ -58,59 +70,39 @@ export default class IndexedCollection {
// the item has become local now, so remove the flags
delete item.remote;
// if (await this.getEncryptionKey()) {
// const encrypted = await this.indexer.encrypt(
// await this.getEncryptionKey(),
// JSON.stringify(item)
// );
// encrypted.dateModified = item.dateModified;
// encrypted.localOnly = item.localOnly;
// encrypted.id = item.id;
// await this.indexer.write(item.id, encrypted);
// } else
await this.indexer.write(item.id, item);
}
removeItem(id) {
this.eventManager.publish(EVENTS.databaseUpdated, id);
return this.updateItem({
removeItem(id: string) {
this.notify(id);
return this.indexer.write(id, {
id,
deleted: true
});
}
async deleteItem(id) {
this.eventManager.publish(EVENTS.databaseUpdated, id);
async deleteItem(id: string) {
this.notify(id);
await this.indexer.deindex(id);
return await this.indexer.remove(id);
}
exists(id) {
exists(id: string) {
return this.indexer.exists(id);
}
async getItem(id) {
async getItemAsync(id: string) {
const item = await this.indexer.read(id);
if (!item) return;
// if ((await this.getEncryptionKey()) && item.iv && item.cipher) {
// return JSON.parse(
// await this.indexer.decrypt(await this.getEncryptionKey(), item)
// );
// } else
return item;
}
async getItems(indices) {
const data = await this.indexer.readMulti(indices);
async getItemsAsync(ids: string[]) {
const data = await this.indexer.readMulti(ids);
return Object.fromEntries(data);
}
async getEncryptionKey() {
if (!this.encryptionKeyFactory) return;
if (this.encryptionKey) return this.encryptionKey;
this.encryptionKey = await this.encryptionKeyFactory();
return this.encryptionKey;
private notify(id: string, item?: Partial<T>) {
// this.eventManager.publish(EVENTS.databaseUpdated, id, item);
}
}

View File

@@ -17,58 +17,61 @@ 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 Storage from "./storage";
import { MaybeDeletedItem } from "../entities";
import { IStorage } from "../interfaces";
export default class Indexer extends Storage {
constructor(storage, type) {
super(storage);
this.type = type;
this.indices = [];
}
export default class Indexer<T> {
private indices: string[] = [];
constructor(
private readonly storage: IStorage,
private readonly type: string
) {}
async init() {
this.indices = (await super.read(this.type, true)) || [];
this.indices = (await this.storage.read(this.type, true)) || [];
}
exists(key) {
exists(key: string) {
return this.indices.includes(key);
}
async index(key) {
async index(key: string) {
if (this.exists(key)) return;
this.indices.push(key);
await super.write(this.type, this.indices);
await this.storage.write(this.type, this.indices);
}
getIndices() {
return this.indices;
}
async deindex(key) {
async deindex(key: string) {
if (!this.exists(key)) return;
this.indices.splice(this.indices.indexOf(key), 1);
await super.write(this.type, this.indices);
await this.storage.write(this.type, this.indices);
}
async clear() {
this.indices = [];
await super.clear();
await this.storage.clear();
}
read(key, isArray = false) {
return super.read(this.makeId(key), isArray);
read(key: string, isArray = false): Promise<MaybeDeletedItem<T> | undefined> {
return this.storage.read(this.makeId(key), isArray);
}
write(key, data) {
return super.write(this.makeId(key), data);
write(key: string, data: MaybeDeletedItem<T>) {
return this.storage.write(this.makeId(key), data);
}
remove(key) {
return super.remove(this.makeId(key));
remove(key: string) {
return this.storage.remove(this.makeId(key));
}
async readMulti(keys) {
const entries = await super.readMulti(keys.map(this.makeId));
async readMulti(keys: string[]) {
const entries = await this.storage.readMulti<MaybeDeletedItem<T>>(
keys.map(this.makeId)
);
entries.forEach((entry) => {
entry[0] = entry[0].replace(`_${this.type}`, "");
});
@@ -76,11 +79,11 @@ export default class Indexer extends Storage {
}
async migrateIndices() {
const keys = (await super.getAllKeys()).filter(
const keys = (await this.storage.getAllKeys()).filter(
(key) => !key.endsWith(`_${this.type}`) && this.exists(key)
);
for (const id of keys) {
const item = await super.read(id);
const item = await this.storage.read<T>(id);
if (!item) continue;
await this.write(id, item);
@@ -88,11 +91,11 @@ export default class Indexer extends Storage {
// remove old ids once they have been moved
for (const id of keys) {
await super.remove(id);
await this.storage.remove(id);
}
}
makeId = (id) => {
private makeId(id: string) {
return `${id}_${this.type}`;
};
}
}

View File

@@ -0,0 +1,176 @@
/*
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 { Cipher } from "@notesnook/crypto/dist/src/types";
import { ValueOf } from "./types";
export type Collections = {
notes: "note" | "trash";
notebooks: "notebook" | "trash";
attachments: "attachment";
reminders: "reminder";
relations: "relation";
content: "tiny" | "tiptap";
shortcuts: "shortcut";
tags: "tag";
colors: "color";
};
export type CollectionType = keyof Collections;
export type ItemType =
| ValueOf<Collections>
// TODO: ideally there should be no extra types here.
// everything should have its own collection
| "topic"
| "settings";
export type Item =
| Note
| Notebook
| Topic
| Attachment
| Tag
| Trash
| Relation;
/**
* Base item type from which all other item types derive containing
* all the common properties
*/
export interface BaseItem<TType extends ItemType> {
id: string;
type: TType;
dateModified: number;
dateCreated: number;
// flags
migrated?: boolean;
remote?: boolean;
synced?: boolean;
}
export type NotebookReference = {
id: string;
topics: string[];
};
export interface DeletedItem {
id: string;
deleted: true;
}
export type MaybeDeletedItem<T> = T | DeletedItem;
export type TrashOrItem<T extends BaseItem<"note" | "notebook">> =
| T
| TrashItem<T>;
export interface Note extends BaseItem<"note"> {
title: string;
notebooks: NotebookReference[];
tags: string[];
dateEdited: number;
pinned: boolean;
locked: boolean;
favorite: boolean;
localOnly: boolean;
conflicted: boolean;
readonly: boolean;
contentId?: string;
sessionId?: string;
headline?: string;
color?: string;
}
export interface Notebook extends BaseItem<"notebook"> {
title: string;
description?: string;
dateEdited: number;
pinned: boolean;
topics: Topic[];
}
export interface Topic extends BaseItem<"topic"> {
title: string;
notebookId: string;
notes: string[];
dateEdited: number;
}
export interface Attachment extends BaseItem<"attachment"> {
noteIds: string[];
iv: string;
salt: string;
length: number;
alg: string; // TODO
chunkSize: number;
key: Cipher;
metadata: {
hash: string;
hashType: string; // TODO
filename: string;
type: string;
};
dateEdited: number;
dateUploaded: number;
dateDeleted: number;
}
export type ItemReference = {
id: string;
type: ItemType;
};
export interface Relation extends BaseItem<"relation"> {
from: ItemReference;
to: ItemReference;
}
interface BaseTag<TType extends "tag" | "color"> extends BaseItem<TType> {
title: string;
alias?: string;
noteIds: string[];
}
export type Tag = BaseTag<"tag">;
export type Color = BaseTag<"color">;
type TrashItem<TItem extends BaseItem<"note" | "notebook">> =
BaseItem<"trash"> & {
title: string;
itemType: TItem["type"];
dateDeleted: number;
} & Omit<TItem, "id" | "type">;
type Trash = TrashItem<Note> | TrashItem<Notebook>;
export function isDeleted<T extends BaseItem<ItemType>>(
item: T | MaybeDeletedItem<T>
): item is DeletedItem {
return "deleted" in item;
}
export function isTrashItem(
item: MaybeDeletedItem<TrashOrItem<BaseItem<"note" | "notebook">>>
): item is Trash {
return !isDeleted(item) && item.type === "trash";
}

View File

@@ -0,0 +1,93 @@
/*
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 { ValueOf } from "./types";
export const CHECK_IDS = {
"note:color": "note:color",
"note:tag": "note:tag",
"note:export": "note:export",
"vault:add": "vault:add",
"notebook:add": "notebook:add",
"backup:encrypt": "backup:encrypt",
"database:sync": "database:sync"
} as const;
export const EVENTS = {
userCheckStatus: "user:checkStatus",
userSubscriptionUpdated: "user:subscriptionUpdated",
userEmailConfirmed: "user:emailConfirmed",
userLoggedIn: "user:loggedIn",
userLoggedOut: "user:loggedOut",
userFetched: "user:fetched",
userSignedUp: "user:signedUp",
userSessionExpired: "user:sessionExpired",
databaseSyncRequested: "db:syncRequested",
databaseMigrated: "db:migrated",
databaseUpdated: "db:updated",
databaseCollectionInitiated: "db:collectionInitiated",
appRefreshRequested: "app:refreshRequested",
noteRemoved: "note:removed",
tokenRefreshed: "token:refreshed",
userUnauthorized: "user:unauthorized",
attachmentsLoading: "attachments:loading",
attachmentDeleted: "attachment:deleted",
mediaAttachmentDownloaded: "attachments:mediaDownloaded",
vaultLocked: "vault:locked",
systemTimeInvalid: "system:invalidTime"
} as const;
export type EventMap = {
[EVENTS.userCheckStatus]: UserCheckStatusEvent;
// "user:subscriptionUpdated":
// "user:emailConfirmed":
// "user:loggedIn":
// "user:loggedOut":
// "user:fetched":
// "user:signedUp":
// "user:sessionExpired":
// "db:syncRequested":
// "db:migrated":
[EVENTS.databaseUpdated]: DatabaseUpdatedEvent;
// "app:refreshRequested":
// "note:removed":
// "token:refreshed":
[EVENTS.attachmentsLoading]: AttachmentsProgressEvent;
// "attachment:deleted":
// "attachments:mediaDownloaded":
};
export type EventName = keyof EventMap;
export type Event = ValueOf<EventMap>;
export interface AttachmentsProgressEvent {
type: "upload" | "download" | "encrypt";
groupId?: string;
total: number;
current?: number;
}
export interface UserCheckStatusEvent {
type: keyof typeof CHECK_IDS;
}
export interface DatabaseUpdatedEvent {
op: "upsert" | "remove" | "delete";
id: string;
}

View File

@@ -0,0 +1,83 @@
/*
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 { Cipher, SerializedKey } from "@notesnook/crypto/dist/src/types";
export interface IStorage {
write<T>(key: string, data: T): Promise<void>;
readMulti<T>(keys: string[]): Promise<[string, T][]>;
read<T>(key: string, isArray?: boolean): Promise<T | undefined>;
remove(key: string): Promise<void>;
clear(): Promise<void>;
getAllKeys(): Promise<string[]>;
encrypt(key: SerializedKey, plainText: string): Promise<Cipher>;
decrypt(key: SerializedKey, cipherData: Cipher): Promise<string | undefined>;
deriveCryptoKey(name: string, credentials: SerializedKey): Promise<void>;
hash(password: string, email: string): Promise<string>;
getCryptoKey(name: string): Promise<string | undefined>;
generateCryptoKey(password: string, salt?: string): Promise<SerializedKey>;
// async generateRandomKey() {
// const passwordBytes = randomBytes(124);
// const password = passwordBytes.toString("base64");
// return await this.storage.generateCryptoKey(password);
// }
}
export interface ICompressor {
compress(data: string): Promise<string>;
decompress(data: string): Promise<string>;
}
type RequestOptions = {
url: string;
chunkSize?: number;
headers: { Authorization: string };
};
type Cancellable<T> = {
execute(): Promise<T>;
cancel(reason?: string): Promise<void>;
};
export interface IFileStorage {
downloadFile(
filename: string,
requestOptions: RequestOptions
): Cancellable<boolean>;
uploadFile(
filename: string,
requestOptions: RequestOptions
): Cancellable<boolean>;
readEncrypted(
filename: string,
encryptionKey: SerializedKey,
cipherData: Cipher
): Promise<string | Uint8Array>;
writeEncryptedBase64(
data: string,
encryptionKey: SerializedKey,
mimeType: string
): Promise<Cipher>;
deleteFile(
filename: string,
requestOptions?: RequestOptions
): Promise<boolean>;
exists(filename: string): Promise<boolean>;
clearFileStorage(): Promise<void>;
hashBase64(data: string): Promise<{ hash: string; type: string }>;
}

View File

@@ -23,8 +23,12 @@ import {
consoleReporter,
format,
LogLevel,
NoopLogger
NoopLogger,
LogMessage,
ILogReporter,
ILogger
} from "@notesnook/logger";
import { IStorage } from "./interfaces";
// Database logger reporter:
// 1. Log to new key on every instance
@@ -35,30 +39,21 @@ import {
const MAX_RETENTION_LENGTH = 14;
class DatabaseLogReporter {
/**
*
* @param {import("./database/storage").default} storage
*/
constructor(storage) {
private readonly writer: DatabaseLogWriter;
constructor(storage: IStorage) {
this.writer = new DatabaseLogWriter(storage);
}
/**
*
* @param {import("@notesnook/logger").LogMessage} log
*/
write(log) {
write(log: LogMessage) {
this.writer.push(log);
}
}
class DatabaseLogWriter {
/**
*
* @param {import("./database/storage").default} storage
*/
constructor(storage) {
this.storage = storage;
private queue: LogMessage[];
private readonly key: string;
constructor(private readonly storage: IStorage) {
this.key = new Date().toLocaleDateString();
this.queue = [];
@@ -67,12 +62,12 @@ class DatabaseLogWriter {
}, 2000);
}
push(message) {
push(message: LogMessage) {
this.queue.push(message);
}
async read() {
return await this.storage.read(this.key, true);
return (await this.storage.read<LogMessage[]>(this.key, true)) || [];
}
async flush() {
@@ -106,13 +101,7 @@ class DatabaseLogWriter {
}
class DatabaseLogManager {
/**
*
* @param {import("./database/storage").default} storage
*/
constructor(storage) {
this.storage = storage;
}
constructor(private readonly storage: IStorage) {}
async get() {
const logKeys = await this.storage.getAllKeys();
@@ -131,14 +120,14 @@ class DatabaseLogManager {
}
}
async delete(key) {
async delete(key: string) {
await this.storage.remove(key);
}
}
function initalize(storage, disableConsoleLogs) {
function initalize(storage: IStorage, disableConsoleLogs = false) {
if (storage) {
let reporters = [new DatabaseLogReporter(storage)];
const reporters: ILogReporter[] = [new DatabaseLogReporter(storage)];
if (process.env.NODE_ENV !== "production" && !disableConsoleLogs)
reporters.push(consoleReporter);
logger = new Logger({
@@ -149,14 +138,7 @@ function initalize(storage, disableConsoleLogs) {
}
}
/**
* @type {import("@notesnook/logger").ILogger}
*/
var logger = new NoopLogger();
/**
* @type {DatabaseLogManager | undefined}
*/
var logManager;
let logger: ILogger = new NoopLogger();
let logManager: DatabaseLogManager | undefined;
export { logger, logManager, initalize, format, LogLevel };

View File

@@ -24,85 +24,107 @@ import { getContentFromData } from "../content-types";
import { CHECK_IDS, checkIsUserPremium } from "../common";
import { addItem, deleteItem } from "../utils/array";
import { formatDate } from "../utils/date";
import { Note, NotebookReference } from "../entities";
export default class Note {
/**
*
* @param {import('../api').default} db
* @param {Object} note
*/
constructor(note, db) {
this._note = note;
this._db = db;
}
interface INoteModel extends Readonly<Note> {
export(
to?: "html" | "md" | "txt",
rawContent?: string
): Promise<string | false | undefined>;
get data() {
return this._note;
}
readonly data: Note;
}
get headline() {
return this._note.headline;
}
export class NoteModel implements INoteModel {
constructor(private readonly note: Note) {}
get title() {
return this._note.title;
return this.note.title;
}
get tags() {
return this._note.tags;
}
get colors() {
return this._note.colors;
}
get id() {
return this._note.id;
}
get notebooks() {
return this._note.notebooks;
return this.note.notebooks;
}
get deleted() {
return this._note.deleted;
get tags() {
return this.note.tags;
}
get dateEdited() {
return this._note.dateEdited;
return this.note.dateEdited;
}
get pinned() {
return this.note.pinned;
}
get locked() {
return this.note.locked;
}
get favorite() {
return this.note.favorite;
}
get localOnly() {
return this.note.localOnly;
}
get conflicted() {
return this.note.conflicted;
}
get readonly() {
return this.note.readonly;
}
get contentId() {
return this.note.contentId;
}
get sessionId() {
return this.note.sessionId;
}
get headline() {
return this.note.headline;
}
get color() {
return this.note.color;
}
get id() {
return this.note.id;
}
get type() {
return this.note.type;
}
get dateModified() {
return this._note.dateModified;
return this.note.dateModified;
}
get dateCreated() {
return this.note.dateCreated;
}
get migrated() {
return this.note.migrated;
}
get remote() {
return this.note.remote;
}
/**
*
* @param {"html"|"md"|"txt"} format - Format to export into
* @param {string?} rawContent - Use this raw content instead of generating itself
* @returns {Promise<string | false | undefined>}
* @deprecated use the model directly
*/
async export(to = "html", rawContent) {
get data() {
return this.note;
}
async export(to = "html", rawContent?: string) {
if (to !== "txt" && !(await checkIsUserPremium(CHECK_IDS.noteExport)))
return false;
const templateData = {
metadata: this.data,
metadata: this,
title: this.title,
editedOn: formatDate(this.dateEdited),
headline: this.headline,
createdOn: formatDate(this.data.dateCreated),
createdOn: formatDate(this.dateCreated),
tags: this.tags.join(", ")
};
const contentItem = await this._db.content.raw(this._note.contentId);
const contentItem = await this._db.content.raw(this.contentId);
if (!contentItem) return false;
const { data, type } = await this._db.content.downloadMedia(
`export-${this.id}`,
contentItem,
false
);
let content = getContentFromData(type, data);
const content = getContentFromData(type, data);
switch (to) {
case "html":
templateData.content = rawContent || content.toHTML();
@@ -119,14 +141,14 @@ export default class Note {
}
async content() {
const content = await this._db.content.raw(this._note.contentId);
const content = await this._db.content.raw(this.contentId);
return content ? content.data : null;
}
async duplicate() {
const content = await this._db.content.raw(this._note.contentId);
const content = await this._db.content.raw(this.contentId);
return await this._db.notes.add({
...this._note,
...this.note,
id: undefined,
content: {
type: content.type,
@@ -136,17 +158,16 @@ export default class Note {
favorite: false,
pinned: false,
contentId: null,
title: this._note.title + " (Copy)",
title: this.title + " (Copy)",
dateEdited: null,
dateCreated: null,
dateModified: null
});
}
async color(color) {
async color(color: string) {
if (!(await checkIsUserPremium(CHECK_IDS.noteColor))) return;
if (this._note.color)
await this._db.colors.untag(this._note.color, this._note.id);
if (this.color) await this._db.colors.untag(this.color, this.id);
await this._db.notes.add({
id: this.id,
color: this._db.colors.sanitize(color)
@@ -154,8 +175,8 @@ export default class Note {
}
async uncolor() {
if (!this._note.color) return;
await this._db.colors.untag(this._note.color, this._note.id);
if (!this.color) return;
await this._db.colors.untag(this.color, this.id);
await this._db.notes.add({
id: this.id,
color: undefined
@@ -169,40 +190,38 @@ export default class Note {
!(await checkIsUserPremium(CHECK_IDS.noteTag))
)
return;
let tagItem = await this._db.tags.add(tag, this._note.id);
if (addItem(this._note.tags, tagItem.title))
await this._db.notes.add(this._note);
let tagItem = await this._db.tags.add(tag, this.id);
if (addItem(this.tags, tagItem.title)) await this._db.notes.add(this);
}
async untag(tag) {
if (deleteItem(this._note.tags, tag)) {
await this._db.notes.add(this._note);
if (deleteItem(this.tags, tag)) {
await this._db.notes.add(this);
} else console.error("This note is not tagged by the specified tag.", tag);
await this._db.tags.untag(tag, this._note.id);
}
_toggle(prop) {
return this._db.notes.add({ id: this._note.id, [prop]: !this._note[prop] });
await this._db.tags.untag(tag, this.id);
}
localOnly() {
return this._toggle("localOnly");
return this.toggle("localOnly");
}
favorite() {
return this._toggle("favorite");
return this.toggle("favorite");
}
pin() {
return this._toggle("pinned");
return this.toggle("pinned");
}
readonly() {
return this._toggle("readonly");
return this.toggle("readonly");
}
synced() {
return !this.data.contentId || this._db.content.exists(this.data.contentId);
get synced() {
return !this.contentId || this._db.content.exists(this.contentId);
}
private toggle(prop: "localOnly" | "readonly" | "pinned" | "favorite") {
return this._db.notes.add({ id: this.id, [prop]: !this[prop] });
}
}

View File

@@ -0,0 +1,62 @@
/*
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/>.
*/
export type GroupOptions = {
groupBy: "abc" | "year" | "month" | "week" | undefined;
sortBy: "dateCreated" | "dateDeleted" | "dateEdited" | "title";
sortDirection: "desc" | "asc";
};
export type GroupingKey =
| "home"
| "notes"
| "notebooks"
| "tags"
| "topics"
| "trash"
| "favorites";
export type ValueOf<T> = T[keyof T];
export type GroupHeader = {
type: "header";
title: string;
};
export type User = {
id: string;
email: string;
isEmailConfirmed: boolean;
mfa: {
isEnabled: boolean;
primaryMethod: string;
secondaryMethod: string;
remainingValidCodes: number;
};
subscription: {
appId: 0;
cancelURL: string | null;
expiry: number;
productId: string;
provider: 0 | 1 | 2 | 3;
start: number;
type: 0 | 1 | 2 | 5 | 6 | 7;
updateURL: string | null;
};
};

View File

@@ -17,45 +17,48 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function findItemAndDelete(array, predicate) {
export function findItemAndDelete<T>(
array: T[],
predicate: (value: T, index: number, obj: T[]) => unknown
) {
return deleteAtIndex(array, array.findIndex(predicate));
}
export function addItem(array, item) {
export function addItem<T>(array: T[], item: T): boolean {
const index = array.indexOf(item);
if (index > -1) return false;
array.push(item);
return true;
}
export function deleteItem(array, item) {
export function deleteItem<T>(array: T[], item: T): boolean {
return deleteAtIndex(array, array.indexOf(item));
}
export function deleteItems(array, ...items) {
for (let item of items) {
export function deleteItems<T>(array: T[], ...items: T[]): void {
for (const item of items) {
deleteItem(array, item);
}
}
export function findById(array, id) {
export function findById<T extends { id: string }>(array: T[], id: string) {
if (!array) return false;
return array.find((item) => item.id === id);
}
export function hasItem(array, item) {
export function hasItem<T>(array: T[], item: T) {
if (!array) return false;
return array.indexOf(item) > -1;
}
function deleteAtIndex(array, index) {
function deleteAtIndex<T>(array: T[], index: number) {
if (index === -1) return false;
array.splice(index, 1);
return true;
}
export function toChunks(array, chunkSize) {
let chunks = [];
export function toChunks<T>(array: T[], chunkSize: number): T[][] {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
const chunk = array.slice(i, i + chunkSize);
chunks.push(chunk);

View File

@@ -0,0 +1,21 @@
/*
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 _clone from "rfdc";
export const clone = _clone();

View File

@@ -19,14 +19,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
const REGEX = /^data:(image\/.+);base64,(.+)/;
function toObject(dataurl) {
function toObject(dataurl: string) {
const regexResult = REGEX.exec(dataurl);
if (!regexResult || regexResult.length < 3) return {};
const [, mime, data] = regexResult;
const [_, mime, data] = regexResult;
return { mime, data };
}
function fromObject({ type, data }) {
function fromObject({ type, data }: { type: string, data: string }) {
if (REGEX.test(data)) return data;
return `data:${type};base64,${data}`;
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function getWeekGroupFromTimestamp(timestamp) {
export function getWeekGroupFromTimestamp(timestamp: number) {
const date = new Date(timestamp);
const { start, end } = getWeek(date);
@@ -37,8 +37,8 @@ const MS_IN_HOUR = 3600000;
* @param {Date} date
* @returns
*/
function getWeek(date) {
var day = date.getDay() || 7;
function getWeek(date: Date) {
const day = date.getDay() || 7;
if (day !== 1) {
const hours = 24 * (day - 1);
date.setTime(date.getTime() - MS_IN_HOUR * hours);
@@ -61,15 +61,9 @@ function getWeek(date) {
return { start, end };
}
/**
*
* @param {number} date
* @param {Intl.DateTimeFormatOptions} options
* @returns
*/
export function formatDate(
date,
options = {
date: number,
options: Intl.DateTimeFormatOptions = {
dateStyle: "medium",
timeStyle: "short"
}

View File

@@ -0,0 +1,97 @@
/*
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/>.
*/
type EventMap = {
[key: string]: unknown;
};
type EventReference<T extends EventMap> = {
name: keyof T;
once: boolean;
};
type EventResult = { result: boolean };
type EventHandler<TPayload = unknown, TResult = unknown> = (
payload: TPayload
) => TResult;
type SubscribeResult = { unsubscribe: () => boolean };
export class EventManager<TEventMap extends EventMap> {
#registry: Map<EventHandler, EventReference<TEventMap>> = new Map<
EventHandler,
EventReference<TEventMap>
>();
unsubscribeAll() {
this.#registry.clear();
}
subscribe<T extends keyof TEventMap>(
name: T,
handler: EventHandler<TEventMap[T], Promise<void> | void>,
once = false
): SubscribeResult {
if (!name || !handler) throw new Error("name and handler are required.");
this.#registry.set(<EventHandler>handler, { name, once });
return { unsubscribe: () => this.unsubscribe(handler) };
}
unsubscribe<T extends keyof TEventMap>(handler: EventHandler<TEventMap[T]>) {
return this.#registry.delete(<EventHandler>handler);
}
publish<T extends keyof TEventMap>(name: T, payload: TEventMap[T]) {
this.#registry.forEach((props, handler) => {
if (props.name === name) handler(payload);
if (props.once) this.#registry.delete(handler);
});
}
async publishWithResult<T extends keyof TEventMap>(
name: T,
payload: TEventMap[T]
): Promise<EventResult[]> {
const handlers: EventHandler[] = [];
this.#registry.forEach((props, handler) => {
if (props.name === name) handlers.push(handler);
if (props.once) this.#registry.delete(handler);
});
if (handlers.length <= 0) return [];
return await Promise.all(
handlers.map(async (handler) => {
const result = await handler(payload);
return isEventResult(result) ? result : { result: false };
})
);
}
remove<T extends keyof TEventMap>(...names: T[]) {
this.#registry.forEach((props, handler) => {
if (names.includes(<T>props.name)) this.#registry.delete(handler);
});
}
}
function isEventResult(result: unknown): result is EventResult {
return typeof result === "object" && !!result && "result" in result;
}

View File

@@ -16,26 +16,14 @@ 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 mimedb from "mime-db";
// type MimeTypeInfo = {
// source: string;
// extensions?: string[];
// charset?: string;
// compressible?: boolean;
// };
let db; // : Record<string, MimeTypeInfo>;
/**
*
* @param {string} filename
* @param {string | undefined} mime
* @returns {string}
*/
export function getFileNameWithExtension(filename, mime) {
export function getFileNameWithExtension(
filename: string,
mime: string | undefined
): string {
if (!mime || mime === "application/octet-stream") return filename;
if (!db) db = require("mime-db");
const mimeData = db[mime];
const mimeData = mimedb[mime];
if (!mimeData || !mimeData.extensions || mimeData.extensions.length === 0)
return filename;
const extension = mimeData.extensions[0];

View File

@@ -17,8 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function extractHostname(url) {
var hostname;
export function extractHostname(url: string) {
let hostname = url;
//find & remove protocol (http, ftp, etc.) and get hostname
if (url.indexOf("//") > -1) {

View File

@@ -21,13 +21,13 @@ import { Parser } from "htmlparser2";
const ALLOWED_ATTRIBUTES = ["href", "src", "data-hash"];
export function isHTMLEqual(one, two) {
export function isHTMLEqual(one: unknown, two: unknown) {
if (typeof one !== "string" || typeof two !== "string") return false;
return toDiffable(one) === toDiffable(two);
}
function toDiffable(html) {
function toDiffable(html: string) {
let text = "";
const parser = new Parser(
{
@@ -41,8 +41,8 @@ function toDiffable(html) {
}
},
{
lowerCaseTags: false,
parseAttributes: true
lowerCaseTags: false
// parseAttributes: true
}
);
parser.end(html);

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { decodeHTML5 } from "entities";
import { Parser } from "htmlparser2";
export const parseHTML = (input) =>
export const parseHTML = (input: string) =>
new globalThis.DOMParser().parseFromString(
wrapIntoHTMLDocument(input),
"text/html"
@@ -31,18 +31,18 @@ export function getDummyDocument() {
return doc;
}
export function getInnerText(element) {
return decodeHTML5(element.innerText || element.textContent);
export function getInnerText(element: HTMLElement) {
return decodeHTML5(element.textContent || element.innerText);
}
function wrapIntoHTMLDocument(input) {
function wrapIntoHTMLDocument(input: string) {
if (typeof input !== "string") return input;
if (input.includes("<body>")) return input;
return `<!doctype html><html lang="en"><head><title>Document Fragment</title></head><body>${input}</body></html>`;
}
export function extractFirstParagraph(html) {
export function extractFirstParagraph(html: string) {
let text = "";
let start = false;
const parser = new Parser(
@@ -69,3 +69,40 @@ export function extractFirstParagraph(html) {
parser.end(html);
return text;
}
type OnTagHandler = (
name: string,
attr: Record<string, string>,
pos: { start: number; end: number }
) => void;
export class HTMLParser {
private parser: Parser;
constructor(options: { ontag?: OnTagHandler } = {}) {
const { ontag } = options;
this.parser = new Parser(
{
onopentag: (name, attr) =>
ontag &&
ontag(name, attr, {
start: this.parser.startIndex,
end: this.parser.endIndex
})
},
{
recognizeSelfClosing: true,
xmlMode: false,
decodeEntities: false,
lowerCaseAttributeNames: false,
lowerCaseTags: false,
recognizeCDATA: false
}
);
}
parse(html: string) {
this.parser.end(html);
this.parser.reset();
}
}

View File

@@ -19,25 +19,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Parser } from "htmlparser2";
type OnTagHandler = (
name: string,
attr: Record<string, string>,
pos: { start: number; end: number }
) => false | { name: string; attr: Record<string, string> } | undefined;
export class HTMLRewriter {
/**
*
* @param {{
* ontag?: (name: string, attr: string, pos: {start: number, end: number}) => false | {name: string, attr: string} | undefined
* }} options
*/
constructor(options = {}) {
private transformed = "";
private currentTag: string | null = null;
private ignoreIndex: number | null = null;
private parser: Parser;
constructor(
options: {
ontag?: OnTagHandler;
} = {}
) {
const { ontag } = options;
/**
* @private
*/
this.transformed = "";
/** @private */
this.currentTag = null;
/** @private */
this.ignoreIndex = null;
/**
* @private
@@ -131,7 +130,7 @@ export class HTMLRewriter {
}
}
transform(html) {
transform(html: string) {
this.parser.end(html);
return this.transformed;
}
@@ -140,48 +139,7 @@ export class HTMLRewriter {
this.parser.reset();
}
/**
* @private
*/
write(html) {
private write(html: string) {
this.transformed += html;
}
}
export class HTMLParser {
/**
*
* @param {{
* ontag?: (name: string, attr: Record<string, string>, pos: {start: number, end: number}) => void
* }} options
*/
constructor(options = {}) {
const { ontag } = options;
/**
* @private
*/
this.parser = new Parser(
{
onopentag: (name, attr) =>
ontag(name, attr, {
start: this.parser.startIndex,
end: this.parser.endIndex
})
},
{
recognizeSelfClosing: true,
xmlMode: false,
decodeEntities: false,
lowerCaseAttributeNames: false,
lowerCaseTags: false,
recognizeCDATA: false
}
);
}
parse(html) {
this.parser.end(html);
this.parser.reset();
}
}

View File

@@ -18,21 +18,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import SparkMD5 from "spark-md5";
import ObjectID from "./object-id";
import { objectId } from "./object-id";
export default function () {
return new ObjectID().toHexString();
return objectId().toString("hex");
}
export function makeId(text) {
export function makeId(text: string) {
return SparkMD5.hash(text);
}
/**
*
* @param {string} noteId id of a note
* @returns {string} An id with postfix of "_index"
*/
export function makeSessionContentId(sessionId) {
export function makeSessionContentId(sessionId: string): string {
return sessionId + "_content";
}

View File

@@ -17,16 +17,16 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
var map = Map;
class MapStub {
override(replacement) {
map = replacement;
}
class _MapStub {
private map: MapConstructor = Map;
get Map() {
return map;
return this.map;
}
set Map(replacement: MapConstructor) {
this.map = replacement;
}
}
const instance = new MapStub();
module.exports = instance;
export const MapStub = new _MapStub();

View File

@@ -0,0 +1,72 @@
/*
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 { randomBytes } from "./random";
export class InvalidObjectId extends Error {
constructor() {
super();
this.name = "InvalidObjectId";
this.message = "Invalid ObjectId length";
}
}
const PROCESS_UNIQUE = randomBytes(5);
let index = ~~(Math.random() * 0xffffff);
export function objectId(date = Date.now()): Buffer {
index = (index + 1) % 0xffffff;
const objectId = new Uint8Array(12);
const time = ~~(date / 1000);
// 4-byte timestamp
new DataView(objectId.buffer, 0, 4).setUint32(0, time);
// 5-byte process unique
objectId[4] = PROCESS_UNIQUE[0];
objectId[5] = PROCESS_UNIQUE[1];
objectId[6] = PROCESS_UNIQUE[2];
objectId[7] = PROCESS_UNIQUE[3];
objectId[8] = PROCESS_UNIQUE[4];
// 3-byte counter
objectId[11] = index & 0xff;
objectId[10] = (index >> 8) & 0xff;
objectId[9] = (index >> 16) & 0xff;
return Buffer.from(objectId.buffer);
}
export function isValid(oid: Uint8Array): boolean {
return oid.length === 12;
}
// export function fromHex(hex: string): Uint8Array {
// const oid = stdDecodeString(hex);
// if (!isValid(oid)) throw new InvalidObjectId();
// return oid;
// }
export function getDate(oid: Uint8Array): Date {
const date = new Date();
const time = new DataView(oid.buffer, 0, 4).getUint32(0);
date.setTime(~~time * 1000);
return date;
}

View File

@@ -17,29 +17,26 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
*
* @param {number} size
* @returns {Buffer}
*/
module.exports.randomBytes = function randomBytes(size) {
export function randomBytes(size: number): Buffer {
if (!global.crypto || !crypto)
throw new Error("Crypto is not supported on this platform.");
if (crypto.randomBytes) return crypto.randomBytes(size);
throw new Error("crypto is not supported on this platform.");
if (!crypto.getRandomValues)
if ("randomBytes" in crypto && typeof crypto.randomBytes === "function")
return crypto.randomBytes(size);
if (!("getRandomValues" in crypto))
throw new Error(
"Crypto.getRandomValues is not available on this platform."
"crypto.getRandomValues is not available on this platform."
);
const buffer = Buffer.allocUnsafe(size);
crypto.getRandomValues(buffer);
return buffer;
};
}
module.exports.randomInt = function () {
const randomBuffer = module.exports.randomBytes(1);
let randomNumber = randomBuffer[0] / 0xff; // / (0xffffffff + 1);
export function randomInt(): number {
const randomBuffer = randomBytes(1);
const randomNumber = randomBuffer[0] / 0xff; // / (0xffffffff + 1);
return Math.floor(randomNumber * 0xffffff);
};
}

View File

@@ -17,45 +17,53 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// SetManipulator MIT Licence © 2016 Edwin Monk-Fromont http://github.com/edmofro
// Based on setOps.js MIT License © 2014 James Abney http://github.com/jabney
// Set operations union, intersection, symmetric difference,
// relative complement, equals. Set operations are fast.
export class SetManipulator {
type KeySelector<T> = (item: T) => string;
type Histogram<T> = Record<string, { value: T; frequency: number }>;
type HistogramEvaluator = (frequency: number) => boolean;
class SetManipulator {
constructor() {}
// Processes a histogram consructed from two arrays, 'a' and 'b'.
// This function is used generically by the below set operation
// methods, a.k.a, 'evaluators', to return some subset of
// a set union, based on frequencies in the histogram.
process(a, b, getKey = (k) => k, evaluator) {
process<T>(
a: T[],
b: T[],
key: KeySelector<T> = (item) => String(item),
evaluator?: HistogramEvaluator
): Histogram<T> | T[] {
// If identity extractor passed in, push it on the stack
//if (identityExtractor) this.pushIdentityExtractor(identityExtractor);
// Create a histogram of 'a'.
const hist = {};
const hist: Histogram<T> = {};
const out = [];
let ukey;
a.forEach((value) => {
ukey = getKey(value);
ukey = key(value);
if (!hist[ukey]) {
hist[ukey] = { value: value, freq: 1 };
hist[ukey] = { value, frequency: 1 };
}
});
// Merge 'b' into the histogram.
b.forEach((value) => {
ukey = getKey(value);
ukey = key(value);
if (hist[ukey]) {
if (hist[ukey].freq === 1) hist[ukey].freq = 3;
} else hist[ukey] = { value: value, freq: 2 };
if (hist[ukey].frequency === 1) hist[ukey].frequency = 3;
} else hist[ukey] = { value: value, frequency: 2 };
});
// Pop any new identity extractor
//if (identityExtractor) this.popIdentityExtractor(identityExtractor);
// Call the given evaluator.
if (evaluator) {
for (const key in hist) {
//if (!hist.hasOwnProperty(key)) continue; // Property from object prototype, skip
if (evaluator(hist[key].freq)) out.push(hist[key].value);
if (evaluator(hist[key].frequency)) out.push(hist[key].value);
}
return out;
}
@@ -64,45 +72,45 @@ export class SetManipulator {
// Join two sets together.
// Set.union([1, 2, 2], [2, 3]) => [1, 2, 3]
union(a, b, getKey) {
return this.process(a, b, getKey, () => true);
union<T>(a: T[], b: T[], key?: KeySelector<T>) {
return <T[]>this.process(a, b, key, () => true);
}
// Return items common to both sets.
// Set.intersection([1, 1, 2], [2, 2, 3]) => [2]
intersection(a, b) {
return this.process(a, b, undefined, (freq) => freq === 3);
intersection<T>(a: T[], b: T[], key: KeySelector<T>) {
return this.process(a, b, key, (freq) => freq === 3);
}
// Symmetric difference. Items from either set that
// are not in both sets.
// Set.difference([1, 1, 2], [2, 3, 3]) => [1, 3]
difference(a, b) {
return this.process(a, b, undefined, (freq) => freq < 3);
difference<T>(a: T[], b: T[], key: KeySelector<T>) {
return this.process(a, b, key, (freq) => freq < 3);
}
// Relative complement. Items from 'a' which are
// not also in 'b'.
// Set.complement([1, 2, 2], [2, 2, 3]) => [3]
complement(a, b) {
return this.process(a, b, undefined, (freq) => freq === 1);
complement<T>(a: T[], b: T[], key: KeySelector<T>) {
return this.process(a, b, key, (freq) => freq === 1);
}
// Returns true if both sets are equivalent, false otherwise.
// Set.equals([1, 1, 2], [1, 2, 2]) => true
// Set.equals([1, 1, 2], [1, 2, 3]) => false
equals(a, b) {
equals<T>(a: T[], b: T[], key: KeySelector<T>) {
let max = 0;
let min = Math.pow(2, 53);
const hist = this.process(a, b);
const hist = <Histogram<T>>this.process(a, b, key);
for (const key in hist) {
// if (!hist.hasOwnProperty(key)) continue; // Property from object prototype, skip
max = Math.max(max, hist[key].freq);
min = Math.min(min, hist[key].freq);
max = Math.max(max, hist[key].frequency);
min = Math.min(min, hist[key].frequency);
}
return min === 3 && max === 3;
}
}
const setManipulator = new SetManipulator();
export default setManipulator;
export const set = new SetManipulator();

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"outDir": "./dist",
"allowJs": true
},
"exclude": ["**/__tests__"],
"include": ["src/", "index.ts"]
}

View File

@@ -1,56 +0,0 @@
/*
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/>.
*/
const _ignore = "";
/**
* @typedef {{
* groupBy: "abc" | "year" | "month" | "week" | "none" | undefined,
* sortBy: "dateCreated" | "dateDeleted" | "dateEdited" | "dateModified" | "title",
* sortDirection: "desc" | "asc"
* }} GroupOptions
*/
/**
* @typedef {"home" | "notes" | "notebooks" | "tags" | "topics" | "trash" | "favorites" | "reminders"} GroupingKey
*/
/**
* @typedef {{
* id: string,
* email: string,
* isEmailConfirmed: boolean,
* mfa: {
* isEnabled: boolean,
* primaryMethod: string,
* secondaryMethod: string,
* remainingValidCodes: number
* },
* subscription: {
* appId: 0,
* cancelURL: string | null,
* expiry: number,
* productId: string,
* provider: 0 | 1 | 2 | 3,
* start: number,
* type: 0 | 1 | 2 | 5 | 6 | 7,
* updateURL: string | null,
* }
* }} User
*/

View File

@@ -1,82 +0,0 @@
/*
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/>.
*/
class EventManager {
constructor() {
this._registry = new Map();
}
unsubscribeAll() {
this._registry.clear();
}
subscribeMulti(names, handler, thisArg) {
names.forEach((name) => {
this.subscribe(name, handler.bind(thisArg));
});
}
subscribe(name, handler, once = false) {
if (!name || !handler) throw new Error("name and handler are required.");
this._registry.set(handler, { name, once });
return { unsubscribe: () => this.unsubscribe(name, handler) };
}
subscribeSingle(name, handler) {
if (!name || !handler) throw new Error("name and handler are required.");
this._registry.forEach((props, handler) => {
if (props.name === name) this._registry.delete(handler);
});
this._registry.set(handler, { name, once: false });
return { unsubscribe: () => this.unsubscribe(name, handler) };
}
unsubscribe(_name, handler) {
return this._registry.delete(handler);
}
publish(name, ...args) {
this._registry.forEach((props, handler) => {
if (props.name === name) {
handler(...args);
if (props.once) this._registry.delete(handler);
}
});
}
async publishWithResult(name, ...args) {
const handlers = [];
this._registry.forEach((props, handler) => {
if (props.name === name) {
handlers.push(handler);
if (props.once) this._registry.delete(handler);
}
});
if (handlers.length <= 0) return true;
return await Promise.all(handlers.map((handler) => handler(...args)));
}
remove(...names) {
this._registry.forEach((props, handler) => {
if (names.includes(props.name)) this._registry.delete(handler);
});
}
}
export default EventManager;

View File

@@ -1,362 +0,0 @@
/*
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/>.
*/
// Copied from https://github.com/williamkapke/bson-objectid
const { randomInt } = require("./random");
var MACHINE_ID = randomInt();
var index = (ObjectID.index = randomInt());
var pid =
(typeof process === "undefined" || typeof process.pid !== "number"
? randomInt()
: process.pid) % 0xffff;
/**
* Determine if an object is Buffer
*
* Author: Feross Aboukhadijeh <feross@feross.org> <http://feross.org>
* License: MIT
*
*/
var isBuffer = function (obj) {
return !!(
obj != null &&
obj.constructor &&
typeof obj.constructor.isBuffer === "function" &&
obj.constructor.isBuffer(obj)
);
};
// Precomputed hex table enables speedy hex string conversion
var hexTable = [];
for (var i = 0; i < 256; i++) {
hexTable[i] = (i <= 15 ? "0" : "") + i.toString(16);
}
// Regular expression that checks for hex value
var checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
// Lookup tables
var decodeLookup = [];
i = 0;
while (i < 10) decodeLookup[0x30 + i] = i++;
while (i < 16) decodeLookup[0x41 - 10 + i] = decodeLookup[0x61 - 10 + i] = i++;
/**
* Create a new immutable ObjectID instance
*
* @class Represents the BSON ObjectID type
* @param {String|Number} id Can be a 24 byte hex string, 12 byte binary string or a Number.
* @return {Object} instance of ObjectID.
*/
function ObjectID(id) {
if (!(this instanceof ObjectID)) return new ObjectID(id);
if (id && (id instanceof ObjectID || id._bsontype === "ObjectID")) return id;
this._bsontype = "ObjectID";
// The most common usecase (blank id, new objectId instance)
if (id == null || typeof id === "number") {
// Generate a new id
this.id = this.generate(id);
// Return the object
return;
}
// Check if the passed in id is valid
var valid = ObjectID.isValid(id);
// Throw an error if it's not a valid setup
if (!valid && id != null) {
throw new Error(
"Argument passed in must be a single String of 12 bytes or a string of 24 hex characters"
);
} else if (valid && typeof id === "string" && id.length === 24) {
return ObjectID.createFromHexString(id);
} else if (id != null && id.length === 12) {
// assume 12 byte string
this.id = id;
} else if (id != null && typeof id.toHexString === "function") {
// Duck-typing to support ObjectId from different npm packages
return id;
} else {
throw new Error(
"Argument passed in must be a single String of 12 bytes or a string of 24 hex characters"
);
}
}
module.exports = ObjectID;
ObjectID.default = ObjectID;
/**
* Creates an ObjectID from a second based number, with the rest of the ObjectID zeroed out. Used for comparisons or sorting the ObjectID.
*
* @param {Number} time an integer number representing a number of seconds.
* @return {ObjectID} return the created ObjectID
* @api public
*/
ObjectID.createFromTime = function (time) {
time = parseInt(time, 10) % 0xffffffff;
return new ObjectID(hex(8, time) + "0000000000000000");
};
/**
* Creates an ObjectID from a hex string representation of an ObjectID.
*
* @param {String} hexString create a ObjectID from a passed in 24 byte hexstring.
* @return {ObjectID} return the created ObjectID
* @api public
*/
ObjectID.createFromHexString = function (hexString) {
// Throw an error if it's not a valid setup
if (
typeof hexString === "undefined" ||
(hexString != null && hexString.length !== 24)
) {
throw new Error(
"Argument passed in must be a single String of 12 bytes or a string of 24 hex characters"
);
}
// Calculate lengths
var data = "";
var i = 0;
while (i < 24) {
data += String.fromCharCode(
(decodeLookup[hexString.charCodeAt(i++)] << 4) |
decodeLookup[hexString.charCodeAt(i++)]
);
}
return new ObjectID(data);
};
/**
* Checks if a value is a valid bson ObjectId
*
* @param {String} objectid Can be a 24 byte hex string or an instance of ObjectID.
* @return {Boolean} return true if the value is a valid bson ObjectID, return false otherwise.
* @api public
*
* THE NATIVE DOCUMENTATION ISN'T CLEAR ON THIS GUY!
* http://mongodb.github.io/node-mongodb-native/api-bson-generated/objectid.html#objectid-isvalid
*/
ObjectID.isValid = function (id) {
if (id == null) return false;
if (typeof id === "number") {
return true;
}
if (typeof id === "string") {
return id.length === 12 || (id.length === 24 && checkForHexRegExp.test(id));
}
if (id instanceof ObjectID) {
return true;
}
if (isBuffer(id)) {
return true;
}
// Duck-Typing detection of ObjectId like objects
if (
typeof id.toHexString === "function" &&
(id.id instanceof Buffer || typeof id.id === "string")
) {
return (
id.id.length === 12 ||
(id.id.length === 24 && checkForHexRegExp.test(id.id))
);
}
return false;
};
ObjectID.prototype = {
constructor: ObjectID,
/**
* Return the ObjectID id as a 24 byte hex string representation
*
* @return {String} return the 24 byte hex string representation.
* @api public
*/
toHexString: function () {
if (!this.id || !this.id.length) {
throw new Error(
"invalid ObjectId, ObjectId.id must be either a string or a Buffer, but is [" +
JSON.stringify(this.id) +
"]"
);
}
if (this.id.length === 24) {
return this.id;
}
if (isBuffer(this.id)) {
return this.id.toString("hex");
}
var hexString = "";
for (var i = 0; i < this.id.length; i++) {
hexString += hexTable[this.id.charCodeAt(i)];
}
return hexString;
},
/**
* Compares the equality of this ObjectID with `otherID`.
*
* @param {Object} otherId ObjectID instance to compare against.
* @return {Boolean} the result of comparing two ObjectID's
* @api public
*/
equals: function (otherId) {
if (otherId instanceof ObjectID) {
return this.toString() === otherId.toString();
} else if (
typeof otherId === "string" &&
ObjectID.isValid(otherId) &&
otherId.length === 12 &&
isBuffer(this.id)
) {
return otherId === this.id.toString("binary");
} else if (
typeof otherId === "string" &&
ObjectID.isValid(otherId) &&
otherId.length === 24
) {
return otherId.toLowerCase() === this.toHexString();
} else if (
typeof otherId === "string" &&
ObjectID.isValid(otherId) &&
otherId.length === 12
) {
return otherId === this.id;
} else if (
otherId != null &&
(otherId instanceof ObjectID || otherId.toHexString)
) {
return otherId.toHexString() === this.toHexString();
} else {
return false;
}
},
/**
* Returns the generation date (accurate up to the second) that this ID was generated.
*
* @return {Date} the generation date
* @api public
*/
getTimestamp: function () {
var timestamp = new Date();
var time;
if (isBuffer(this.id)) {
time =
this.id[3] |
(this.id[2] << 8) |
(this.id[1] << 16) |
(this.id[0] << 24);
} else {
time =
this.id.charCodeAt(3) |
(this.id.charCodeAt(2) << 8) |
(this.id.charCodeAt(1) << 16) |
(this.id.charCodeAt(0) << 24);
}
timestamp.setTime(Math.floor(time) * 1000);
return timestamp;
},
/**
* Generate a 12 byte id buffer used in ObjectID's
*
* @method
* @param {number} [time] optional parameter allowing to pass in a second based timestamp.
* @return {string} return the 12 byte id buffer string.
*/
generate: function (time) {
if ("number" !== typeof time) {
time = ~~(Date.now() / 1000);
}
//keep it in the ring!
time = parseInt(time, 10) % 0xffffffff;
var inc = next();
return String.fromCharCode(
(time >> 24) & 0xff,
(time >> 16) & 0xff,
(time >> 8) & 0xff,
time & 0xff,
(MACHINE_ID >> 16) & 0xff,
(MACHINE_ID >> 8) & 0xff,
MACHINE_ID & 0xff,
(pid >> 8) & 0xff,
pid & 0xff,
(inc >> 16) & 0xff,
(inc >> 8) & 0xff,
inc & 0xff
);
}
};
function next() {
return (index = (index + 1) % 0xffffff);
}
function hex(length, n) {
n = n.toString(16);
return n.length === length ? n : "00000000".substring(n.length, length) + n;
}
// function buffer(str) {
// var i = 0,
// out = [];
// if (str.length === 24)
// for (; i < 24; out.push(parseInt(str[i] + str[i + 1], 16)), i += 2);
// else if (str.length === 12) for (; i < 12; out.push(str.charCodeAt(i)), i++);
// return out;
// }
var inspect =
(Symbol && Symbol.for && Symbol.for("nodejs.util.inspect.custom")) ||
"inspect";
/**
* Converts to a string representation of this Id.
*
* @return {String} return the 24 byte hex string representation.
* @api private
*/
ObjectID.prototype[inspect] = function () {
return "ObjectID(" + this + ")";
};
ObjectID.prototype.toJSON = ObjectID.prototype.toHexString;
ObjectID.prototype.toString = ObjectID.prototype.toHexString;

View File

@@ -0,0 +1,42 @@
/*
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/>.
*/
/*
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 { defineConfig } from "vitest/config";
export default defineConfig({
test: {}
});