mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 06:59:31 +01:00
core: changes according to client needs
This commit is contained in:
@@ -28,7 +28,7 @@ import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
|
|||||||
import { randomBytes } from "../../src/utils/random";
|
import { randomBytes } from "../../src/utils/random";
|
||||||
import { GroupOptions, Note, Notebook } from "../../src/types";
|
import { GroupOptions, Note, Notebook } from "../../src/types";
|
||||||
import { NoteContent } from "../../src/collections/session-content";
|
import { NoteContent } from "../../src/collections/session-content";
|
||||||
import { SqliteDriver } from "kysely";
|
import { SqliteDialect } from "kysely";
|
||||||
import BetterSQLite3 from "better-sqlite3";
|
import BetterSQLite3 from "better-sqlite3";
|
||||||
|
|
||||||
const TEST_NOTEBOOK: Partial<Notebook> = {
|
const TEST_NOTEBOOK: Partial<Notebook> = {
|
||||||
@@ -48,7 +48,7 @@ function databaseTest() {
|
|||||||
eventsource: EventSource,
|
eventsource: EventSource,
|
||||||
fs: FS,
|
fs: FS,
|
||||||
compressor: Compressor,
|
compressor: Compressor,
|
||||||
sqlite: new SqliteDriver({ database: BetterSQLite3(":memory:") })
|
dialect: new SqliteDialect({ database: BetterSQLite3(":memory:") })
|
||||||
});
|
});
|
||||||
return db.init().then(() => db);
|
return db.init().then(() => db);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@notesnook/core",
|
"name": "@notesnook/core",
|
||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"main": "dist/api/index.js",
|
"main": "dist/index.js",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -60,8 +60,13 @@ import {
|
|||||||
import TokenManager from "./token-manager";
|
import TokenManager from "./token-manager";
|
||||||
import { Attachment } from "../types";
|
import { Attachment } from "../types";
|
||||||
import { Settings } from "../collections/settings";
|
import { Settings } from "../collections/settings";
|
||||||
import { DatabaseAccessor, DatabaseSchema, createDatabase } from "../database";
|
import {
|
||||||
import { Kysely, SqliteDriver, Transaction } from "kysely";
|
DatabaseAccessor,
|
||||||
|
DatabaseSchema,
|
||||||
|
SQLiteOptions,
|
||||||
|
createDatabase
|
||||||
|
} from "../database";
|
||||||
|
import { Kysely, Transaction } from "kysely";
|
||||||
import { CachedCollection } from "../database/cached-collection";
|
import { CachedCollection } from "../database/cached-collection";
|
||||||
|
|
||||||
type EventSourceConstructor = new (
|
type EventSourceConstructor = new (
|
||||||
@@ -69,11 +74,12 @@ type EventSourceConstructor = new (
|
|||||||
init: EventSourceInit & { headers?: Record<string, string> }
|
init: EventSourceInit & { headers?: Record<string, string> }
|
||||||
) => EventSource;
|
) => EventSource;
|
||||||
type Options = {
|
type Options = {
|
||||||
sqlite: SqliteDriver;
|
sqliteOptions: SQLiteOptions;
|
||||||
storage: IStorage;
|
storage: IStorage;
|
||||||
eventsource?: EventSourceConstructor;
|
eventsource?: EventSourceConstructor;
|
||||||
fs: IFileStorage;
|
fs: IFileStorage;
|
||||||
compressor: ICompressor;
|
compressor: ICompressor;
|
||||||
|
batchSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// const DIFFERENCE_THRESHOLD = 20 * 1000;
|
// const DIFFERENCE_THRESHOLD = 20 * 1000;
|
||||||
@@ -131,6 +137,7 @@ class Database {
|
|||||||
transaction = (
|
transaction = (
|
||||||
executor: (tr: Transaction<DatabaseSchema>) => void | Promise<void>
|
executor: (tr: Transaction<DatabaseSchema>) => void | Promise<void>
|
||||||
) => {
|
) => {
|
||||||
|
console.time("transaction");
|
||||||
return this.transactionMutex.runExclusive(() =>
|
return this.transactionMutex.runExclusive(() =>
|
||||||
this.sql()
|
this.sql()
|
||||||
.transaction()
|
.transaction()
|
||||||
@@ -139,11 +146,14 @@ class Database {
|
|||||||
await executor(tr);
|
await executor(tr);
|
||||||
this._transaction = undefined;
|
this._transaction = undefined;
|
||||||
})
|
})
|
||||||
.finally(() => (this._transaction = undefined))
|
.finally(() => {
|
||||||
|
console.timeEnd("transaction");
|
||||||
|
this._transaction = undefined;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
private options?: Options;
|
options?: Options;
|
||||||
EventSource?: EventSourceConstructor;
|
EventSource?: EventSourceConstructor;
|
||||||
eventSource?: EventSource | null;
|
eventSource?: EventSource | null;
|
||||||
|
|
||||||
@@ -160,7 +170,6 @@ class Database {
|
|||||||
vault = new Vault(this);
|
vault = new Vault(this);
|
||||||
lookup = new Lookup(this);
|
lookup = new Lookup(this);
|
||||||
backup = new Backup(this);
|
backup = new Backup(this);
|
||||||
legacySettings = new LegacySettings(this);
|
|
||||||
settings = new Settings(this);
|
settings = new Settings(this);
|
||||||
migrations = new Migrations(this);
|
migrations = new Migrations(this);
|
||||||
monographs = new Monographs(this);
|
monographs = new Monographs(this);
|
||||||
@@ -193,7 +202,10 @@ class Database {
|
|||||||
* @deprecated only kept here for migration purposes
|
* @deprecated only kept here for migration purposes
|
||||||
*/
|
*/
|
||||||
legacyNotes = new CachedCollection(this.storage, "notes", this.eventManager);
|
legacyNotes = new CachedCollection(this.storage, "notes", this.eventManager);
|
||||||
|
/**
|
||||||
|
* @deprecated only kept here for migration purposes
|
||||||
|
*/
|
||||||
|
legacySettings = new LegacySettings(this);
|
||||||
// constructor() {
|
// constructor() {
|
||||||
// this.sseMutex = new Mutex();
|
// this.sseMutex = new Mutex();
|
||||||
// // this.lastHeartbeat = undefined; // { local: 0, server: 0 };
|
// // this.lastHeartbeat = undefined; // { local: 0, server: 0 };
|
||||||
@@ -229,7 +241,8 @@ class Database {
|
|||||||
this.disconnectSSE();
|
this.disconnectSSE();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.options) this._sql = await createDatabase(this.options.sqlite);
|
if (this.options)
|
||||||
|
this._sql = await createDatabase(this.options.sqliteOptions);
|
||||||
|
|
||||||
await this._validate();
|
await this._validate();
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import http from "../utils/http";
|
import http from "../utils/http";
|
||||||
import Constants from "../utils/constants";
|
import Constants from "../utils/constants";
|
||||||
import Database from ".";
|
import Database from ".";
|
||||||
import { isDeleted } from "../types";
|
import { Note, isDeleted } from "../types";
|
||||||
import { isUnencryptedContent } from "../collections/content";
|
import { isUnencryptedContent } from "../collections/content";
|
||||||
import { Cipher } from "@notesnook/crypto";
|
import { Cipher } from "@notesnook/crypto";
|
||||||
|
import { isFalse } from "../database";
|
||||||
|
|
||||||
type BaseMonograph = {
|
type BaseMonograph = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -163,9 +164,15 @@ export class Monographs {
|
|||||||
this.monographs.splice(this.monographs.indexOf(noteId), 1);
|
this.monographs.splice(this.monographs.indexOf(noteId), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async all() {
|
get all() {
|
||||||
if (!this.monographs.length) return [];
|
return this.db.notes.collection.createFilter<Note>(
|
||||||
return await this.db.notes.all.items(this.monographs);
|
(qb) =>
|
||||||
|
qb
|
||||||
|
.where(isFalse("dateDeleted"))
|
||||||
|
.where(isFalse("deleted"))
|
||||||
|
.where("id", "in", this.monographs),
|
||||||
|
this.db.options?.batchSize
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(monographId: string) {
|
get(monographId: string) {
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ class Sync {
|
|||||||
|
|
||||||
const collectionType = SYNC_COLLECTIONS_MAP[itemType];
|
const collectionType = SYNC_COLLECTIONS_MAP[itemType];
|
||||||
const collection = this.db[collectionType].collection;
|
const collection = this.db[collectionType].collection;
|
||||||
const localItems = await collection.items(chunk.items.map((i) => i.id));
|
const localItems = await collection.records(chunk.items.map((i) => i.id));
|
||||||
let items: (MaybeDeletedItem<Item> | undefined)[] = [];
|
let items: (MaybeDeletedItem<Item> | undefined)[] = [];
|
||||||
if (itemType === "content") {
|
if (itemType === "content") {
|
||||||
items = await Promise.all(
|
items = await Promise.all(
|
||||||
|
|||||||
@@ -365,8 +365,9 @@ export class Attachments implements ICollection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get pending() {
|
get pending() {
|
||||||
return this.collection.createFilter<Attachment>((qb) =>
|
return this.collection.createFilter<Attachment>(
|
||||||
qb.where(isFalse("dateUploaded"))
|
(qb) => qb.where(isFalse("dateUploaded")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +384,9 @@ export class Attachments implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get deleted() {
|
get deleted() {
|
||||||
return this.collection.createFilter<Attachment>((qb) =>
|
return this.collection.createFilter<Attachment>(
|
||||||
qb.where("dateDeleted", "is not", null)
|
(qb) => qb.where("dateDeleted", "is not", null),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +414,9 @@ export class Attachments implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Attachment>((qb) =>
|
return this.collection.createFilter<Attachment>(
|
||||||
qb.where(isFalse("deleted"))
|
(qb) => qb.where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ export class Colors implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Color>((qb) =>
|
return this.collection.createFilter<Color>(
|
||||||
qb.where(isFalse("deleted"))
|
(qb) => qb.where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import Database from "../api";
|
import Database from "../api";
|
||||||
import { getOutputType } from "./attachments";
|
import { getOutputType } from "./attachments";
|
||||||
import { SQLCollection } from "../database/sql-collection";
|
import { SQLCollection } from "../database/sql-collection";
|
||||||
|
import { NoteContent } from "./session-content";
|
||||||
|
|
||||||
export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({
|
export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({
|
||||||
noteId,
|
noteId,
|
||||||
@@ -73,49 +74,67 @@ export class Content implements ICollection {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const id = content.id || getId();
|
const id = content.id || getId();
|
||||||
const oldContent = content.id ? await this.get(content.id) : undefined;
|
|
||||||
const noteId = oldContent?.noteId || content.noteId;
|
|
||||||
if (!noteId) throw new Error("No noteId found to link the content to.");
|
|
||||||
|
|
||||||
const encryptedData = isCipher(content.data)
|
if (!content.noteId)
|
||||||
? content.data
|
throw new Error("No noteId found to link the content to.");
|
||||||
: oldContent && isCipher(oldContent.data)
|
if (!content.type) throw new Error("Please specify content's type.");
|
||||||
? oldContent.data
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const unencryptedData =
|
const encryptedData = isCipher(content.data) ? content.data : undefined;
|
||||||
typeof content.data === "string"
|
let unencryptedData =
|
||||||
? content.data
|
typeof content.data === "string" ? content.data : undefined;
|
||||||
: oldContent && typeof oldContent.data === "string"
|
|
||||||
? oldContent.data
|
|
||||||
: "<p></p>";
|
|
||||||
|
|
||||||
const contentItem: ContentItem = {
|
if (unencryptedData)
|
||||||
type: "tiptap",
|
unencryptedData = await this.extractAttachments({
|
||||||
noteId,
|
type: content.type,
|
||||||
id,
|
data: unencryptedData,
|
||||||
|
noteId: content.noteId
|
||||||
|
});
|
||||||
|
|
||||||
dateEdited: content.dateEdited || oldContent?.dateEdited || Date.now(),
|
if (content.id && (await this.exists(content.id))) {
|
||||||
dateCreated: content.dateCreated || oldContent?.dateCreated || Date.now(),
|
const contentData = encryptedData
|
||||||
dateModified: Date.now(),
|
? { locked: true as const, data: encryptedData }
|
||||||
localOnly: content.localOnly || !!oldContent?.localOnly,
|
: unencryptedData
|
||||||
|
? { locked: false as const, data: unencryptedData }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
conflicted: content.conflicted || oldContent?.conflicted,
|
await this.collection.update([content.id], {
|
||||||
dateResolved: content.dateResolved || oldContent?.dateResolved,
|
dateEdited: content.dateEdited,
|
||||||
|
localOnly: content.localOnly,
|
||||||
|
conflicted: content.conflicted,
|
||||||
|
dateResolved: content.dateResolved,
|
||||||
|
...contentData
|
||||||
|
});
|
||||||
|
|
||||||
...(encryptedData
|
if (content.sessionId && contentData)
|
||||||
? { locked: true, data: encryptedData }
|
await this.db.noteHistory.add(content.sessionId, {
|
||||||
: { locked: false, data: unencryptedData })
|
noteId: content.noteId,
|
||||||
};
|
type: content.type,
|
||||||
|
...contentData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const contentItem: ContentItem = {
|
||||||
|
type: "tiptap",
|
||||||
|
noteId: content.noteId,
|
||||||
|
id,
|
||||||
|
|
||||||
await this.collection.upsert(
|
dateEdited: content.dateEdited || Date.now(),
|
||||||
contentItem.locked
|
dateCreated: content.dateCreated || Date.now(),
|
||||||
? contentItem
|
dateModified: Date.now(),
|
||||||
: await this.extractAttachments(contentItem)
|
localOnly: !!content.localOnly,
|
||||||
);
|
|
||||||
|
|
||||||
if (content.sessionId)
|
conflicted: content.conflicted,
|
||||||
await this.db.noteHistory.add(content.sessionId, contentItem);
|
dateResolved: content.dateResolved,
|
||||||
|
|
||||||
|
...(encryptedData
|
||||||
|
? { locked: true, data: encryptedData }
|
||||||
|
: { locked: false, data: unencryptedData || "<p></p>" })
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.collection.upsert(contentItem);
|
||||||
|
|
||||||
|
if (content.sessionId)
|
||||||
|
await this.db.noteHistory.add(content.sessionId, contentItem);
|
||||||
|
}
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -254,18 +273,21 @@ export class Content implements ICollection {
|
|||||||
await this.add(contentItem);
|
await this.add(contentItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractAttachments(contentItem: UnencryptedContentItem) {
|
async extractAttachments(
|
||||||
if (contentItem.localOnly) return contentItem;
|
contentItem: NoteContent<false> & { noteId: string }
|
||||||
|
) {
|
||||||
|
// if (contentItem.localOnly) return contentItem;
|
||||||
|
|
||||||
const content = getContentFromData(contentItem.type, contentItem.data);
|
const content = getContentFromData(contentItem.type, contentItem.data);
|
||||||
if (!content) return contentItem;
|
if (!content) return contentItem.data;
|
||||||
const { data, hashes } = await content.extractAttachments(
|
const { data, hashes } = await content.extractAttachments(
|
||||||
this.db.attachments.save
|
this.db.attachments.save
|
||||||
);
|
);
|
||||||
|
|
||||||
const noteAttachments = await this.db.relations
|
const noteAttachments = await this.db.relations
|
||||||
.from({ type: "note", id: contentItem.noteId }, "attachment")
|
.from({ type: "note", id: contentItem.noteId }, "attachment")
|
||||||
.resolve();
|
.selector.filter.select(["id", "hash"])
|
||||||
|
.execute();
|
||||||
|
|
||||||
const toDelete = noteAttachments.filter((attachment) => {
|
const toDelete = noteAttachments.filter((attachment) => {
|
||||||
return hashes.every((hash) => hash !== attachment.hash);
|
return hashes.every((hash) => hash !== attachment.hash);
|
||||||
@@ -281,11 +303,12 @@ export class Content implements ICollection {
|
|||||||
id: contentItem.noteId,
|
id: contentItem.noteId,
|
||||||
type: "note"
|
type: "note"
|
||||||
},
|
},
|
||||||
attachment
|
{ id: attachment.id, type: "attachment" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const hash of toAdd) {
|
for (const hash of toAdd) {
|
||||||
|
// TODO: only get id instead of the whole object
|
||||||
const attachment = await this.db.attachments.attachment(hash);
|
const attachment = await this.db.attachments.attachment(hash);
|
||||||
if (!attachment) continue;
|
if (!attachment) continue;
|
||||||
await this.db.relations.add(
|
await this.db.relations.add(
|
||||||
@@ -297,11 +320,11 @@ export class Content implements ICollection {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toAdd.length > 0) {
|
// if (toAdd.length > 0) {
|
||||||
contentItem.dateModified = Date.now();
|
// contentItem.dateModified = Date.now();
|
||||||
}
|
// }
|
||||||
contentItem.data = data;
|
// contentItem.data = data;
|
||||||
return contentItem;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async cleanup() {
|
// async cleanup() {
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import Database from "../api";
|
import Database from "../api";
|
||||||
import { isCipher } from "../database/crypto";
|
import { isCipher } from "../database/crypto";
|
||||||
import { SQLCollection } from "../database/sql-collection";
|
import { SQLCollection } from "../database/sql-collection";
|
||||||
import { ContentItem, HistorySession, isDeleted } from "../types";
|
import { HistorySession, isDeleted } from "../types";
|
||||||
import { makeSessionContentId } from "../utils/id";
|
import { makeSessionContentId } from "../utils/id";
|
||||||
import { ICollection } from "./collection";
|
import { ICollection } from "./collection";
|
||||||
import { SessionContent } from "./session-content";
|
import { NoteContent, SessionContent } from "./session-content";
|
||||||
|
|
||||||
export class NoteHistory implements ICollection {
|
export class NoteHistory implements ICollection {
|
||||||
name = "notehistory";
|
name = "notehistory";
|
||||||
@@ -60,39 +60,56 @@ export class NoteHistory implements ICollection {
|
|||||||
return history as HistorySession[];
|
return history as HistorySession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(sessionId: string, content: ContentItem) {
|
async add(
|
||||||
|
sessionId: string,
|
||||||
|
content: NoteContent<boolean> & { noteId: string; locked: boolean }
|
||||||
|
) {
|
||||||
const { noteId, locked } = content;
|
const { noteId, locked } = content;
|
||||||
sessionId = `${noteId}_${sessionId}`;
|
sessionId = `${noteId}_${sessionId}`;
|
||||||
const oldSession = await this.collection.get(sessionId);
|
|
||||||
|
|
||||||
if (oldSession && isDeleted(oldSession)) return;
|
if (await this.collection.exists(sessionId)) {
|
||||||
|
await this.collection.update([sessionId], { locked });
|
||||||
const session: HistorySession = {
|
} else {
|
||||||
type: "session",
|
await this.collection.upsert({
|
||||||
id: sessionId,
|
type: "session",
|
||||||
sessionContentId: makeSessionContentId(sessionId),
|
id: sessionId,
|
||||||
noteId,
|
sessionContentId: makeSessionContentId(sessionId),
|
||||||
dateCreated: oldSession ? oldSession.dateCreated : Date.now(),
|
noteId,
|
||||||
dateModified: Date.now(),
|
dateCreated: Date.now(),
|
||||||
localOnly: true,
|
dateModified: Date.now(),
|
||||||
locked
|
localOnly: true,
|
||||||
};
|
locked
|
||||||
|
});
|
||||||
await this.collection.upsert(session);
|
}
|
||||||
await this.sessionContent.add(sessionId, content, locked);
|
await this.sessionContent.add(sessionId, content, locked);
|
||||||
await this.cleanup(noteId);
|
await this.cleanup(noteId);
|
||||||
|
|
||||||
return session;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanup(noteId: string, limit = this.versionsLimit) {
|
private async cleanup(noteId: string, limit = this.versionsLimit) {
|
||||||
const history = await this.get(noteId, "asc");
|
const history = await this.db
|
||||||
if (history.length === 0 || history.length < limit) return;
|
.sql()
|
||||||
const deleteCount = history.length - limit;
|
.selectFrom("notehistory")
|
||||||
for (let i = 0; i < deleteCount; i++) {
|
.where("noteId", "==", noteId)
|
||||||
const session = history[i];
|
.orderBy(`dateModified asc`)
|
||||||
|
.select(["id", "sessionContentId"])
|
||||||
|
.offset(limit)
|
||||||
|
.limit(10)
|
||||||
|
.$narrowType<{ id: string; sessionContentId: string }>()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
for (const session of history) {
|
||||||
await this._remove(session);
|
await this._remove(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const history = await this.get(noteId, "asc");
|
||||||
|
// if (history.length === 0 || history.length < limit) return;
|
||||||
|
// const deleteCount = history.length - limit;
|
||||||
|
// for (let i = 0; i < deleteCount; i++) {
|
||||||
|
// const session = history[i];
|
||||||
|
// await this._remove(session);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
async content(sessionId: string) {
|
async content(sessionId: string) {
|
||||||
@@ -131,7 +148,7 @@ export class NoteHistory implements ICollection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _remove(session: HistorySession) {
|
private async _remove(session: { id: string; sessionContentId: string }) {
|
||||||
await this.collection.delete([session.id]);
|
await this.collection.delete([session.id]);
|
||||||
await this.sessionContent.remove(session.sessionContentId);
|
await this.sessionContent.remove(session.sessionContentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,17 +79,20 @@ export class Notebooks implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Notebook>((qb) =>
|
return this.collection.createFilter<Notebook>(
|
||||||
qb.where(isFalse("dateDeleted")).where(isFalse("deleted"))
|
(qb) => qb.where(isFalse("dateDeleted")).where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get pinned() {
|
get pinned() {
|
||||||
return this.collection.createFilter<Notebook>((qb) =>
|
return this.collection.createFilter<Notebook>(
|
||||||
qb
|
(qb) =>
|
||||||
.where(isFalse("dateDeleted"))
|
qb
|
||||||
.where(isFalse("deleted"))
|
.where(isFalse("dateDeleted"))
|
||||||
.where("pinned", "==", true)
|
.where(isFalse("deleted"))
|
||||||
|
.where("pinned", "==", true),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export class Notes implements ICollection {
|
|||||||
throw new Error("Please use db.notes.merge to merge remote notes.");
|
throw new Error("Please use db.notes.merge to merge remote notes.");
|
||||||
|
|
||||||
const id = item.id || getId();
|
const id = item.id || getId();
|
||||||
|
|
||||||
const oldNote = await this.note(id);
|
const oldNote = await this.note(id);
|
||||||
|
|
||||||
const note = {
|
const note = {
|
||||||
@@ -74,59 +75,61 @@ export class Notes implements ICollection {
|
|||||||
if (!oldNote && !item.content && !item.contentId && !item.title)
|
if (!oldNote && !item.content && !item.contentId && !item.title)
|
||||||
throw new Error("Note must have a title or content.");
|
throw new Error("Note must have a title or content.");
|
||||||
|
|
||||||
if (item.content && item.content.data && item.content.type) {
|
await this.db.transaction(async () => {
|
||||||
const { type, data } = item.content;
|
if (item.content && item.content.data && item.content.type) {
|
||||||
|
const { type, data } = item.content;
|
||||||
|
|
||||||
const content = getContentFromData(type, data);
|
const content = getContentFromData(type, data);
|
||||||
if (!content) throw new Error("Invalid content type.");
|
if (!content) throw new Error("Invalid content type.");
|
||||||
|
|
||||||
note.contentId = await this.db.content.add({
|
note.contentId = await this.db.content.add({
|
||||||
noteId: id,
|
noteId: id,
|
||||||
sessionId: note.sessionId,
|
sessionId: note.sessionId,
|
||||||
id: note.contentId,
|
id: note.contentId,
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
localOnly: !!note.localOnly
|
localOnly: !!note.localOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
note.headline = note.locked ? "" : getNoteHeadline(content);
|
||||||
|
if (oldNote) note.dateEdited = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.localOnly !== undefined) {
|
||||||
|
await this.db.content.add({
|
||||||
|
id: note.contentId,
|
||||||
|
localOnly: !!item.localOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteTitle = await this.getNoteTitle(note, oldNote, note.headline);
|
||||||
|
if (oldNote && oldNote.title !== noteTitle) note.dateEdited = Date.now();
|
||||||
|
|
||||||
|
await this.collection.upsert({
|
||||||
|
id,
|
||||||
|
contentId: note.contentId,
|
||||||
|
type: "note",
|
||||||
|
|
||||||
|
title: noteTitle,
|
||||||
|
headline: note.headline,
|
||||||
|
|
||||||
|
notebooks: note.notebooks || undefined,
|
||||||
|
|
||||||
|
pinned: !!note.pinned,
|
||||||
|
locked: !!note.locked,
|
||||||
|
favorite: !!note.favorite,
|
||||||
|
localOnly: !!note.localOnly,
|
||||||
|
conflicted: !!note.conflicted,
|
||||||
|
readonly: !!note.readonly,
|
||||||
|
|
||||||
|
dateCreated: note.dateCreated || Date.now(),
|
||||||
|
dateEdited:
|
||||||
|
item.dateEdited || note.dateEdited || note.dateCreated || Date.now(),
|
||||||
|
dateModified: note.dateModified || Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
note.headline = note.locked ? "" : getNoteHeadline(content);
|
if (!oldNote) this.totalNotes++;
|
||||||
if (oldNote) note.dateEdited = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.localOnly !== undefined) {
|
|
||||||
await this.db.content.add({
|
|
||||||
id: note.contentId,
|
|
||||||
localOnly: !!item.localOnly
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteTitle = await this.getNoteTitle(note, oldNote, note.headline);
|
|
||||||
if (oldNote && oldNote.title !== noteTitle) note.dateEdited = Date.now();
|
|
||||||
|
|
||||||
await this.collection.upsert({
|
|
||||||
id,
|
|
||||||
contentId: note.contentId,
|
|
||||||
type: "note",
|
|
||||||
|
|
||||||
title: noteTitle,
|
|
||||||
headline: note.headline,
|
|
||||||
|
|
||||||
notebooks: note.notebooks || undefined,
|
|
||||||
|
|
||||||
pinned: !!note.pinned,
|
|
||||||
locked: !!note.locked,
|
|
||||||
favorite: !!note.favorite,
|
|
||||||
localOnly: !!note.localOnly,
|
|
||||||
conflicted: !!note.conflicted,
|
|
||||||
readonly: !!note.readonly,
|
|
||||||
|
|
||||||
dateCreated: note.dateCreated || Date.now(),
|
|
||||||
dateEdited:
|
|
||||||
item.dateEdited || note.dateEdited || note.dateCreated || Date.now(),
|
|
||||||
dateModified: note.dateModified || Date.now()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!oldNote) this.totalNotes++;
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +152,9 @@ export class Notes implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Note>((qb) =>
|
return this.collection.createFilter<Note>(
|
||||||
qb.where(isFalse("dateDeleted")).where(isFalse("deleted"))
|
(qb) => qb.where(isFalse("dateDeleted")).where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,38 +169,46 @@ export class Notes implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get pinned() {
|
get pinned() {
|
||||||
return this.collection.createFilter<Note>((qb) =>
|
return this.collection.createFilter<Note>(
|
||||||
qb
|
(qb) =>
|
||||||
.where(isFalse("dateDeleted"))
|
qb
|
||||||
.where(isFalse("deleted"))
|
.where(isFalse("dateDeleted"))
|
||||||
.where("pinned", "==", true)
|
.where(isFalse("deleted"))
|
||||||
|
.where("pinned", "==", true),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get conflicted() {
|
get conflicted() {
|
||||||
return this.collection.createFilter<Note>((qb) =>
|
return this.collection.createFilter<Note>(
|
||||||
qb
|
(qb) =>
|
||||||
.where(isFalse("dateDeleted"))
|
qb
|
||||||
.where(isFalse("deleted"))
|
.where(isFalse("dateDeleted"))
|
||||||
.where("conflicted", "==", true)
|
.where(isFalse("deleted"))
|
||||||
|
.where("conflicted", "==", true),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get favorites() {
|
get favorites() {
|
||||||
return this.collection.createFilter<Note>((qb) =>
|
return this.collection.createFilter<Note>(
|
||||||
qb
|
(qb) =>
|
||||||
.where(isFalse("dateDeleted"))
|
qb
|
||||||
.where(isFalse("deleted"))
|
.where(isFalse("dateDeleted"))
|
||||||
.where("favorite", "==", true)
|
.where(isFalse("deleted"))
|
||||||
|
.where("favorite", "==", true),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get locked() {
|
get locked() {
|
||||||
return this.collection.createFilter<Note>((qb) =>
|
return this.collection.createFilter<Note>(
|
||||||
qb
|
(qb) =>
|
||||||
.where(isFalse("dateDeleted"))
|
qb
|
||||||
.where(isFalse("deleted"))
|
.where(isFalse("dateDeleted"))
|
||||||
.where("locked", "==", true)
|
.where(isFalse("deleted"))
|
||||||
|
.where("locked", "==", true),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,21 +311,19 @@ export class Notes implements ICollection {
|
|||||||
dateCreated: undefined,
|
dateCreated: undefined,
|
||||||
dateModified: undefined
|
dateModified: undefined
|
||||||
});
|
});
|
||||||
if (!duplicateId) return;
|
if (!duplicateId) continue;
|
||||||
|
|
||||||
for (const notebook of await this.db.relations
|
for (const relation of await this.db.relations
|
||||||
.to(note, "notebook")
|
.to(note, "notebook")
|
||||||
.get()) {
|
.get()) {
|
||||||
await this.db.relations.add(
|
await this.db.relations.add(
|
||||||
{ type: "notebook", id: notebook },
|
{ type: "notebook", id: relation.fromId },
|
||||||
{
|
{
|
||||||
id: duplicateId,
|
id: duplicateId,
|
||||||
type: "note"
|
type: "note"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return duplicateId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
import { makeId } from "../utils/id";
|
import { makeId } from "../utils/id";
|
||||||
import { ICollection } from "./collection";
|
import { ICollection } from "./collection";
|
||||||
import {
|
import { Relation, ItemMap, ItemReference, ValueOf, ItemType } from "../types";
|
||||||
Relation,
|
|
||||||
ItemMap,
|
|
||||||
ItemReference,
|
|
||||||
ValueOf,
|
|
||||||
MaybeDeletedItem
|
|
||||||
} from "../types";
|
|
||||||
import Database from "../api";
|
import Database from "../api";
|
||||||
import { SQLCollection } from "../database/sql-collection";
|
import { FilteredSelector, SQLCollection } from "../database/sql-collection";
|
||||||
import { DatabaseAccessor, DatabaseSchema, isFalse } from "../database";
|
import { DatabaseSchema, isFalse } from "../database";
|
||||||
import { SelectQueryBuilder } from "kysely";
|
import { SelectQueryBuilder } from "kysely";
|
||||||
|
|
||||||
|
type ItemReferences = { type: ItemType; ids: string[] };
|
||||||
export class Relations implements ICollection {
|
export class Relations implements ICollection {
|
||||||
name = "relations";
|
name = "relations";
|
||||||
readonly collection: SQLCollection<"relations", Relation>;
|
readonly collection: SQLCollection<"relations", Relation>;
|
||||||
@@ -39,6 +34,7 @@ export class Relations implements ICollection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
await this.buildCache();
|
||||||
// return this.collection.init();
|
// return this.collection.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,18 +51,71 @@ export class Relations implements ICollection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from(
|
||||||
|
reference: ItemReference | ItemReferences,
|
||||||
|
types: (keyof RelatableTable)[]
|
||||||
|
): RelationsArray<keyof RelatableTable>;
|
||||||
from<TType extends keyof RelatableTable>(
|
from<TType extends keyof RelatableTable>(
|
||||||
reference: ItemReference,
|
reference: ItemReference | ItemReferences,
|
||||||
type: TType
|
type: TType
|
||||||
|
): RelationsArray<TType>;
|
||||||
|
from<TType extends keyof RelatableTable = keyof RelatableTable>(
|
||||||
|
reference: ItemReference | ItemReferences,
|
||||||
|
type: TType | keyof RelatableTable[]
|
||||||
) {
|
) {
|
||||||
return new RelationsArray(this.db, reference, type, "from");
|
return new RelationsArray(
|
||||||
|
this.db,
|
||||||
|
reference,
|
||||||
|
Array.isArray(type) ? type : [type],
|
||||||
|
"from"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to(
|
||||||
|
reference: ItemReference | ItemReferences,
|
||||||
|
types: (keyof RelatableTable)[]
|
||||||
|
): RelationsArray<keyof RelatableTable>;
|
||||||
to<TType extends keyof RelatableTable>(
|
to<TType extends keyof RelatableTable>(
|
||||||
reference: ItemReference,
|
reference: ItemReference | ItemReferences,
|
||||||
type: TType
|
type: TType
|
||||||
|
): RelationsArray<TType>;
|
||||||
|
to<TType extends keyof RelatableTable = keyof RelatableTable>(
|
||||||
|
reference: ItemReference | ItemReferences,
|
||||||
|
type: TType | keyof RelatableTable[]
|
||||||
) {
|
) {
|
||||||
return new RelationsArray(this.db, reference, type, "to");
|
return new RelationsArray(
|
||||||
|
this.db,
|
||||||
|
reference,
|
||||||
|
Array.isArray(type) ? type : [type],
|
||||||
|
"to"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCache: Map<string, string[]> = new Map();
|
||||||
|
toCache: Map<string, string[]> = new Map();
|
||||||
|
async buildCache() {
|
||||||
|
console.time("cache build");
|
||||||
|
this.fromCache.clear();
|
||||||
|
this.toCache.clear();
|
||||||
|
|
||||||
|
console.time("query");
|
||||||
|
const relations = await this.db
|
||||||
|
.sql()
|
||||||
|
.selectFrom("relations")
|
||||||
|
.select(["toId", "fromId"])
|
||||||
|
.$narrowType<{ toId: string; fromId: string }>()
|
||||||
|
.execute();
|
||||||
|
console.timeEnd("query");
|
||||||
|
for (const { fromId, toId } of relations) {
|
||||||
|
const fromIds = this.fromCache.get(fromId) || [];
|
||||||
|
fromIds.push(toId);
|
||||||
|
this.fromCache.set(fromId, fromIds);
|
||||||
|
|
||||||
|
const toIds = this.toCache.get(toId) || [];
|
||||||
|
toIds.push(fromId);
|
||||||
|
this.toCache.set(toId, toIds);
|
||||||
|
}
|
||||||
|
console.timeEnd("cache build");
|
||||||
}
|
}
|
||||||
|
|
||||||
// get raw() {
|
// get raw() {
|
||||||
@@ -146,32 +195,40 @@ const TABLE_MAP = {
|
|||||||
type RelatableTable = typeof TABLE_MAP;
|
type RelatableTable = typeof TABLE_MAP;
|
||||||
|
|
||||||
class RelationsArray<TType extends keyof RelatableTable> {
|
class RelationsArray<TType extends keyof RelatableTable> {
|
||||||
private table: ValueOf<RelatableTable> = TABLE_MAP[this.type];
|
private table: ValueOf<RelatableTable> = TABLE_MAP[this.types[0]];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Database,
|
private readonly db: Database,
|
||||||
private readonly reference: ItemReference,
|
private readonly reference: ItemReference | ItemReferences,
|
||||||
private readonly type: TType,
|
private readonly types: TType[],
|
||||||
private readonly direction: "from" | "to"
|
private readonly direction: "from" | "to"
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async resolve(limit?: number): Promise<ItemMap[TType][]> {
|
get selector() {
|
||||||
const items = await this.db
|
return new FilteredSelector<ItemMap[TType]>(
|
||||||
.sql()
|
this.table,
|
||||||
.selectFrom(this.table)
|
this.db
|
||||||
.where("id", "in", (b) =>
|
.sql()
|
||||||
b
|
.selectFrom<keyof DatabaseSchema>(this.table)
|
||||||
.selectFrom("relations")
|
.where("id", "in", (b) =>
|
||||||
.$call((eb) =>
|
b
|
||||||
this.buildRelationsQuery()(
|
.selectFrom("relations")
|
||||||
eb as SelectQueryBuilder<DatabaseSchema, "relations", unknown>
|
.$call((eb) =>
|
||||||
|
this.buildRelationsQuery()(
|
||||||
|
eb as SelectQueryBuilder<DatabaseSchema, "relations", unknown>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
// TODO: check if we need to index deleted field.
|
||||||
|
.where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(limit?: number) {
|
||||||
|
const items = await this.selector.filter
|
||||||
.$if(limit !== undefined && limit > 0, (b) => b.limit(limit!))
|
.$if(limit !== undefined && limit > 0, (b) => b.limit(limit!))
|
||||||
.selectAll()
|
.selectAll()
|
||||||
// TODO: check if we need to index deleted field.
|
|
||||||
.where(isFalse("deleted"))
|
|
||||||
.execute();
|
.execute();
|
||||||
return items as unknown as ItemMap[TType][];
|
return items as unknown as ItemMap[TType][];
|
||||||
}
|
}
|
||||||
@@ -196,12 +253,20 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get() {
|
async get() {
|
||||||
const ids = await this.db
|
const relations = await this.db
|
||||||
.sql()
|
.sql()
|
||||||
.selectFrom("relations")
|
.selectFrom("relations")
|
||||||
.$call(this.buildRelationsQuery())
|
.$call(this.buildRelationsQuery())
|
||||||
|
.clearSelect()
|
||||||
|
.select(["fromId", "toId", "fromType", "toType"])
|
||||||
|
.$narrowType<{
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
fromType: keyof ItemMap;
|
||||||
|
toType: keyof ItemMap;
|
||||||
|
}>()
|
||||||
.execute();
|
.execute();
|
||||||
return ids.map((i) => i.id);
|
return relations;
|
||||||
}
|
}
|
||||||
|
|
||||||
async count() {
|
async count() {
|
||||||
@@ -216,13 +281,13 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
|||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
async has(id: string) {
|
async has(...ids: string[]) {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.sql()
|
.sql()
|
||||||
.selectFrom("relations")
|
.selectFrom("relations")
|
||||||
.$call(this.buildRelationsQuery())
|
.$call(this.buildRelationsQuery())
|
||||||
.clearSelect()
|
.clearSelect()
|
||||||
.where(this.direction === "from" ? "toId" : "fromId", "==", id)
|
.where(this.direction === "from" ? "toId" : "fromId", "in", ids)
|
||||||
.select((b) => b.fn.count<number>("id").as("count"))
|
.select((b) => b.fn.count<number>("id").as("count"))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!result) return false;
|
if (!result) return false;
|
||||||
@@ -240,15 +305,26 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
|||||||
) => {
|
) => {
|
||||||
if (this.direction === "to") {
|
if (this.direction === "to") {
|
||||||
return builder
|
return builder
|
||||||
.where("fromType", "==", this.type)
|
.where(
|
||||||
|
"fromType",
|
||||||
|
this.types.length > 1 ? "in" : "==",
|
||||||
|
this.types.length > 1 ? this.types : this.types[0]
|
||||||
|
)
|
||||||
.where("toType", "==", this.reference.type)
|
.where("toType", "==", this.reference.type)
|
||||||
.where("toId", "==", this.reference.id)
|
.where(
|
||||||
|
"toId",
|
||||||
|
isItemReferences(this.reference) ? "in" : "==",
|
||||||
|
isItemReferences(this.reference)
|
||||||
|
? this.reference.ids
|
||||||
|
: this.reference.id
|
||||||
|
)
|
||||||
.$if(
|
.$if(
|
||||||
this.type === "note" && this.db.trash.cache.notes.length > 0,
|
this.types.includes("note" as TType) &&
|
||||||
|
this.db.trash.cache.notes.length > 0,
|
||||||
(b) => b.where("fromId", "not in", this.db.trash.cache.notes)
|
(b) => b.where("fromId", "not in", this.db.trash.cache.notes)
|
||||||
)
|
)
|
||||||
.$if(
|
.$if(
|
||||||
this.type === "notebook" &&
|
this.types.includes("notebook" as TType) &&
|
||||||
this.db.trash.cache.notebooks.length > 0,
|
this.db.trash.cache.notebooks.length > 0,
|
||||||
(b) => b.where("fromId", "not in", this.db.trash.cache.notebooks)
|
(b) => b.where("fromId", "not in", this.db.trash.cache.notebooks)
|
||||||
)
|
)
|
||||||
@@ -256,15 +332,26 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
|||||||
.$narrowType<{ id: string }>();
|
.$narrowType<{ id: string }>();
|
||||||
} else {
|
} else {
|
||||||
return builder
|
return builder
|
||||||
.where("toType", "==", this.type)
|
.where(
|
||||||
|
"toType",
|
||||||
|
this.types.length > 1 ? "in" : "==",
|
||||||
|
this.types.length > 1 ? this.types : this.types[0]
|
||||||
|
)
|
||||||
.where("fromType", "==", this.reference.type)
|
.where("fromType", "==", this.reference.type)
|
||||||
.where("fromId", "==", this.reference.id)
|
.where(
|
||||||
|
"fromId",
|
||||||
|
isItemReferences(this.reference) ? "in" : "==",
|
||||||
|
isItemReferences(this.reference)
|
||||||
|
? this.reference.ids
|
||||||
|
: this.reference.id
|
||||||
|
)
|
||||||
.$if(
|
.$if(
|
||||||
this.type === "note" && this.db.trash.cache.notes.length > 0,
|
this.types.includes("note" as TType) &&
|
||||||
|
this.db.trash.cache.notes.length > 0,
|
||||||
(b) => b.where("toId", "not in", this.db.trash.cache.notes)
|
(b) => b.where("toId", "not in", this.db.trash.cache.notes)
|
||||||
)
|
)
|
||||||
.$if(
|
.$if(
|
||||||
this.type === "notebook" &&
|
this.types.includes("notebook" as TType) &&
|
||||||
this.db.trash.cache.notebooks.length > 0,
|
this.db.trash.cache.notebooks.length > 0,
|
||||||
(b) => b.where("toId", "not in", this.db.trash.cache.notebooks)
|
(b) => b.where("toId", "not in", this.db.trash.cache.notebooks)
|
||||||
)
|
)
|
||||||
@@ -274,3 +361,9 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isItemReferences(
|
||||||
|
ref: ItemReference | ItemReferences
|
||||||
|
): ref is ItemReferences {
|
||||||
|
return "ids" in ref;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ICollection } from "./collection";
|
|||||||
import { Reminder } from "../types";
|
import { Reminder } from "../types";
|
||||||
import Database from "../api";
|
import Database from "../api";
|
||||||
import { SQLCollection } from "../database/sql-collection";
|
import { SQLCollection } from "../database/sql-collection";
|
||||||
|
import { isFalse } from "../database";
|
||||||
|
|
||||||
dayjs.extend(isTomorrow);
|
dayjs.extend(isTomorrow);
|
||||||
dayjs.extend(isSameOrBefore);
|
dayjs.extend(isSameOrBefore);
|
||||||
@@ -84,9 +85,12 @@ export class Reminders implements ICollection {
|
|||||||
// return this.collection.raw();
|
// return this.collection.raw();
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// get all() {
|
get all() {
|
||||||
// return this.collection.items();
|
return this.collection.createFilter<Reminder>(
|
||||||
// }
|
(qb) => qb.where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
exists(itemId: string) {
|
exists(itemId: string) {
|
||||||
return this.collection.exists(itemId);
|
return this.collection.exists(itemId);
|
||||||
|
|||||||
@@ -52,17 +52,17 @@ export class SessionContent implements ICollection {
|
|||||||
locked: TLocked
|
locked: TLocked
|
||||||
) {
|
) {
|
||||||
if (!sessionId || !content) return;
|
if (!sessionId || !content) return;
|
||||||
const data =
|
// const data =
|
||||||
locked || isCipher(content.data)
|
// locked || isCipher(content.data)
|
||||||
? content.data
|
// ? content.data
|
||||||
: await this.db.compressor().compress(content.data);
|
// : await this.db.compressor().compress(content.data);
|
||||||
|
|
||||||
await this.collection.upsert({
|
await this.collection.upsert({
|
||||||
type: "sessioncontent",
|
type: "sessioncontent",
|
||||||
id: makeSessionContentId(sessionId),
|
id: makeSessionContentId(sessionId),
|
||||||
data,
|
data: content.data,
|
||||||
contentType: content.type,
|
contentType: content.type,
|
||||||
compressed: !locked,
|
compressed: false,
|
||||||
localOnly: true,
|
localOnly: true,
|
||||||
locked,
|
locked,
|
||||||
dateCreated: Date.now(),
|
dateCreated: Date.now(),
|
||||||
|
|||||||
@@ -18,17 +18,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Database from "../api";
|
import Database from "../api";
|
||||||
import { isFalse } from "../database";
|
import { SQLCachedCollection } from "../database/sql-cached-collection";
|
||||||
import { SQLCollection } from "../database/sql-collection";
|
|
||||||
import { Shortcut } from "../types";
|
import { Shortcut } from "../types";
|
||||||
import { ICollection } from "./collection";
|
import { ICollection } from "./collection";
|
||||||
|
|
||||||
const ALLOWED_SHORTCUT_TYPES = ["notebook", "topic", "tag"];
|
const ALLOWED_SHORTCUT_TYPES = ["notebook", "topic", "tag"];
|
||||||
export class Shortcuts implements ICollection {
|
export class Shortcuts implements ICollection {
|
||||||
name = "shortcuts";
|
name = "shortcuts";
|
||||||
readonly collection: SQLCollection<"shortcuts", Shortcut>;
|
readonly collection: SQLCachedCollection<"shortcuts", Shortcut>;
|
||||||
constructor(private readonly db: Database) {
|
constructor(private readonly db: Database) {
|
||||||
this.collection = new SQLCollection(db.sql, "shortcuts", db.eventManager);
|
this.collection = new SQLCachedCollection(
|
||||||
|
db.sql,
|
||||||
|
"shortcuts",
|
||||||
|
db.eventManager
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -82,33 +85,25 @@ export class Shortcuts implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Shortcut>((qb) =>
|
return this.collection.items();
|
||||||
qb.where(isFalse("deleted"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get() {
|
async resolved() {
|
||||||
// return this.all.reduce((prev, shortcut) => {
|
const tagIds: string[] = [];
|
||||||
// const {
|
const notebookIds: string[] = [];
|
||||||
// item: { id }
|
for (const shortcut of this.all) {
|
||||||
// } = shortcut;
|
if (shortcut.itemType === "notebook") notebookIds.push(shortcut.itemId);
|
||||||
// let item: Notebook | Topic | Tag | null | undefined = null;
|
else if (shortcut.itemType === "tag") tagIds.push(shortcut.itemId);
|
||||||
// switch (shortcut.item.type) {
|
}
|
||||||
// case "notebook": {
|
return [
|
||||||
// const notebook = this.db.notebooks.notebook(id);
|
...(notebookIds.length > 0
|
||||||
// item = notebook ? notebook.data : null;
|
? await this.db.notebooks.all.items(notebookIds)
|
||||||
// break;
|
: []),
|
||||||
// }
|
...(tagIds.length > 0 ? await this.db.tags.all.items(tagIds) : [])
|
||||||
// case "tag":
|
];
|
||||||
// item = this.db.tags.tag(id);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// if (item) prev.push(item);
|
|
||||||
// return prev;
|
|
||||||
// }, [] as (Notebook | Topic | Tag)[]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(id: string) {
|
exists(id: string) {
|
||||||
return this.collection.exists(id);
|
return this.collection.exists(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ export class Tags implements ICollection {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
get all() {
|
get all() {
|
||||||
return this.collection.createFilter<Tag>((qb) =>
|
return this.collection.createFilter<Tag>(
|
||||||
qb.where(isFalse("deleted"))
|
(qb) => qb.where(isFalse("deleted")),
|
||||||
|
this.db.options?.batchSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default class Backup {
|
|||||||
collection: DatabaseCollection<T, B>,
|
collection: DatabaseCollection<T, B>,
|
||||||
state: BackupState
|
state: BackupState
|
||||||
) {
|
) {
|
||||||
for await (const item of collection.stream()) {
|
for await (const item of collection.stream() as any) {
|
||||||
const data = JSON.stringify(item);
|
const data = JSON.stringify(item);
|
||||||
state.buffer.push(data);
|
state.buffer.push(data);
|
||||||
state.bufferLength += data.length;
|
state.bufferLength += data.length;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { StorageAccessor } from "../interfaces";
|
|||||||
import EventManager from "../utils/event-manager";
|
import EventManager from "../utils/event-manager";
|
||||||
import { chunkedIterate } from "../utils/array";
|
import { chunkedIterate } from "../utils/array";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated only kept here for migration purposes
|
||||||
|
*/
|
||||||
export class CachedCollection<
|
export class CachedCollection<
|
||||||
TCollectionType extends CollectionType,
|
TCollectionType extends CollectionType,
|
||||||
T extends ItemMap[Collections[TCollectionType]]
|
T extends ItemMap[Collections[TCollectionType]]
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import {
|
import {
|
||||||
Migrator,
|
Migrator,
|
||||||
Kysely,
|
Kysely,
|
||||||
SqliteAdapter,
|
|
||||||
SqliteIntrospector,
|
|
||||||
SqliteQueryCompiler,
|
|
||||||
sql,
|
sql,
|
||||||
Driver,
|
|
||||||
KyselyPlugin,
|
KyselyPlugin,
|
||||||
PluginTransformQueryArgs,
|
PluginTransformQueryArgs,
|
||||||
PluginTransformResultArgs,
|
PluginTransformResultArgs,
|
||||||
@@ -37,7 +33,8 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
ColumnType,
|
ColumnType,
|
||||||
ExpressionBuilder,
|
ExpressionBuilder,
|
||||||
ReferenceExpression
|
ReferenceExpression,
|
||||||
|
Dialect
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import {
|
import {
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -119,9 +116,8 @@ export interface DatabaseCollection<T, IsAsync extends boolean> {
|
|||||||
put(items: (T | undefined)[]): Promise<void>;
|
put(items: (T | undefined)[]): Promise<void>;
|
||||||
update(ids: string[], partial: Partial<T>): Promise<void>;
|
update(ids: string[], partial: Partial<T>): Promise<void>;
|
||||||
ids(options: GroupOptions): AsyncOrSyncResult<IsAsync, string[]>;
|
ids(options: GroupOptions): AsyncOrSyncResult<IsAsync, string[]>;
|
||||||
items(
|
records(
|
||||||
ids: string[],
|
ids: string[]
|
||||||
sortOptions?: GroupOptions
|
|
||||||
): AsyncOrSyncResult<
|
): AsyncOrSyncResult<
|
||||||
IsAsync,
|
IsAsync,
|
||||||
Record<string, MaybeDeletedItem<T> | undefined>
|
Record<string, MaybeDeletedItem<T> | undefined>
|
||||||
@@ -198,14 +194,17 @@ const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createDatabase(driver: Driver) {
|
export type SQLiteOptions = {
|
||||||
|
dialect: Dialect;
|
||||||
|
journalMode?: "WAL" | "MEMORY" | "OFF" | "PERSIST" | "TRUNCATE" | "DELETE";
|
||||||
|
synchronous?: "normal" | "extra" | "full" | "off";
|
||||||
|
lockingMode?: "normal" | "exclusive";
|
||||||
|
cacheSize?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
export async function createDatabase(options: SQLiteOptions) {
|
||||||
const db = new Kysely<DatabaseSchema>({
|
const db = new Kysely<DatabaseSchema>({
|
||||||
dialect: {
|
dialect: options.dialect,
|
||||||
createAdapter: () => new SqliteAdapter(),
|
|
||||||
createDriver: () => driver,
|
|
||||||
createIntrospector: (db) => new SqliteIntrospector(db),
|
|
||||||
createQueryCompiler: () => new SqliteQueryCompiler()
|
|
||||||
},
|
|
||||||
plugins: [new SqliteBooleanPlugin()]
|
plugins: [new SqliteBooleanPlugin()]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,8 +213,28 @@ export async function createDatabase(driver: Driver) {
|
|||||||
provider: new NNMigrationProvider()
|
provider: new NNMigrationProvider()
|
||||||
});
|
});
|
||||||
|
|
||||||
await sql`PRAGMA journal_mode = WAL`.execute(db);
|
await sql`PRAGMA journal_mode = ${sql.raw(
|
||||||
await sql`PRAGMA synchronous = normal`.execute(db);
|
options.journalMode || "WAL"
|
||||||
|
)}`.execute(db);
|
||||||
|
|
||||||
|
await sql`PRAGMA synchronous = ${sql.raw(
|
||||||
|
options.synchronous || "normal"
|
||||||
|
)}`.execute(db);
|
||||||
|
|
||||||
|
if (options.pageSize)
|
||||||
|
await sql`PRAGMA page_size = ${sql.raw(
|
||||||
|
options.pageSize.toString()
|
||||||
|
)}`.execute(db);
|
||||||
|
|
||||||
|
if (options.cacheSize)
|
||||||
|
await sql`PRAGMA cache_size = ${sql.raw(
|
||||||
|
options.cacheSize.toString()
|
||||||
|
)}`.execute(db);
|
||||||
|
|
||||||
|
if (options.lockingMode)
|
||||||
|
await sql`PRAGMA locking_mode = ${sql.raw(options.lockingMode)}`.execute(
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
await migrator.migrateToLatest();
|
await migrator.migrateToLatest();
|
||||||
|
|
||||||
@@ -240,6 +259,8 @@ export class SqliteBooleanPlugin implements KyselyPlugin {
|
|||||||
args: PluginTransformResultArgs
|
args: PluginTransformResultArgs
|
||||||
): Promise<QueryResult<UnknownRow>> {
|
): Promise<QueryResult<UnknownRow>> {
|
||||||
for (const row of args.result.rows) {
|
for (const row of args.result.rows) {
|
||||||
|
if (typeof row !== "object") continue;
|
||||||
|
|
||||||
for (const key in row) {
|
for (const key in row) {
|
||||||
if (BooleanProperties.has(key as BooleanFields)) {
|
if (BooleanProperties.has(key as BooleanFields)) {
|
||||||
row[key] = row[key] === 1 ? true : false;
|
row[key] = row[key] === 1 ? true : false;
|
||||||
|
|||||||
@@ -281,7 +281,6 @@ async function createFTS5Table(
|
|||||||
const ref_ai = sql.raw(table + "_ai");
|
const ref_ai = sql.raw(table + "_ai");
|
||||||
const ref_ad = sql.raw(table + "_ad");
|
const ref_ad = sql.raw(table + "_ad");
|
||||||
const ref_au = sql.raw(table + "_au");
|
const ref_au = sql.raw(table + "_au");
|
||||||
|
|
||||||
const indexed_cols = sql.raw(indexedColumns.join(", "));
|
const indexed_cols = sql.raw(indexedColumns.join(", "));
|
||||||
const unindexed_cols =
|
const unindexed_cols =
|
||||||
unindexedColumns.length > 0
|
unindexedColumns.length > 0
|
||||||
@@ -289,11 +288,9 @@ async function createFTS5Table(
|
|||||||
: sql.raw("");
|
: sql.raw("");
|
||||||
const new_indexed_cols = sql.raw(indexedColumns.join(", new."));
|
const new_indexed_cols = sql.raw(indexedColumns.join(", new."));
|
||||||
const old_indexed_cols = sql.raw(indexedColumns.join(", old."));
|
const old_indexed_cols = sql.raw(indexedColumns.join(", old."));
|
||||||
|
|
||||||
await sql`CREATE VIRTUAL TABLE ${ref_fts} USING fts5(
|
await sql`CREATE VIRTUAL TABLE ${ref_fts} USING fts5(
|
||||||
id UNINDEXED, ${unindexed_cols} ${indexed_cols}, content='${sql.raw(table)}'
|
id UNINDEXED, ${unindexed_cols} ${indexed_cols}, content='${sql.raw(table)}'
|
||||||
)`.execute(db);
|
)`.execute(db);
|
||||||
|
|
||||||
insertConditions = [
|
insertConditions = [
|
||||||
"(new.deleted is null or new.deleted == 0)",
|
"(new.deleted is null or new.deleted == 0)",
|
||||||
...insertConditions
|
...insertConditions
|
||||||
@@ -304,13 +301,11 @@ async function createFTS5Table(
|
|||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO ${ref_fts}(rowid, id, ${indexed_cols}) VALUES (new.rowid, new.id, new.${new_indexed_cols});
|
INSERT INTO ${ref_fts}(rowid, id, ${indexed_cols}) VALUES (new.rowid, new.id, new.${new_indexed_cols});
|
||||||
END;`.execute(db);
|
END;`.execute(db);
|
||||||
|
|
||||||
await sql`CREATE TRIGGER ${ref_ad} AFTER DELETE ON ${ref}
|
await sql`CREATE TRIGGER ${ref_ad} AFTER DELETE ON ${ref}
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO ${ref_fts} (${ref_fts}, rowid, id, ${indexed_cols})
|
INSERT INTO ${ref_fts} (${ref_fts}, rowid, id, ${indexed_cols})
|
||||||
VALUES ('delete', old.rowid, old.id, old.${old_indexed_cols});
|
VALUES ('delete', old.rowid, old.id, old.${old_indexed_cols});
|
||||||
END;`.execute(db);
|
END;`.execute(db);
|
||||||
|
|
||||||
await sql`CREATE TRIGGER ${ref_au} AFTER UPDATE ON ${ref}
|
await sql`CREATE TRIGGER ${ref_au} AFTER UPDATE ON ${ref}
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO ${ref_fts} (${ref_fts}, rowid, id, ${indexed_cols})
|
INSERT INTO ${ref_fts} (${ref_fts}, rowid, id, ${indexed_cols})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class SQLCachedCollection<
|
|||||||
> implements DatabaseCollection<T, false>
|
> implements DatabaseCollection<T, false>
|
||||||
{
|
{
|
||||||
private collection: SQLCollection<TCollectionType, T>;
|
private collection: SQLCollection<TCollectionType, T>;
|
||||||
private cache = new Map<string, MaybeDeletedItem<T>>();
|
private cache = new Map<string, MaybeDeletedItem<T> | undefined>();
|
||||||
// private cachedItems?: T[];
|
// private cachedItems?: T[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -41,6 +41,8 @@ export class SQLCachedCollection<
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.collection.init();
|
await this.collection.init();
|
||||||
|
const records = await this.collection.records([]);
|
||||||
|
this.cache = new Map(Object.entries(records));
|
||||||
// const data = await this.collection.indexer.readMulti(
|
// const data = await this.collection.indexer.readMulti(
|
||||||
// this.collection.indexer.indices
|
// this.collection.indexer.indices
|
||||||
// );
|
// );
|
||||||
@@ -114,10 +116,7 @@ export class SQLCachedCollection<
|
|||||||
return Array.from(this.cache.keys());
|
return Array.from(this.cache.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
items(
|
records(ids: string[]): Record<string, MaybeDeletedItem<T> | undefined> {
|
||||||
ids: string[],
|
|
||||||
_sortOptions?: GroupOptions
|
|
||||||
): Record<string, MaybeDeletedItem<T> | undefined> {
|
|
||||||
const items: Record<string, MaybeDeletedItem<T> | undefined> = {};
|
const items: Record<string, MaybeDeletedItem<T> | undefined> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
items[id] = this.cache.get(id);
|
items[id] = this.cache.get(id);
|
||||||
@@ -125,13 +124,30 @@ export class SQLCachedCollection<
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items(ids?: string[]): T[] {
|
||||||
|
const items: T[] = [];
|
||||||
|
if (ids) {
|
||||||
|
for (const id of ids) {
|
||||||
|
const item = this.cache.get(id);
|
||||||
|
if (!item || isDeleted(item)) continue;
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [_key, value] of this.cache) {
|
||||||
|
if (!value || isDeleted(value)) continue;
|
||||||
|
items.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
*unsynced(
|
*unsynced(
|
||||||
after: number,
|
after: number,
|
||||||
chunkSize: number
|
chunkSize: number
|
||||||
): IterableIterator<MaybeDeletedItem<T>[]> {
|
): IterableIterator<MaybeDeletedItem<T>[]> {
|
||||||
let chunk: MaybeDeletedItem<T>[] = [];
|
let chunk: MaybeDeletedItem<T>[] = [];
|
||||||
for (const [_key, value] of this.cache) {
|
for (const [_key, value] of this.cache) {
|
||||||
if (value.dateModified && value.dateModified > after) {
|
if (value && value.dateModified && value.dateModified > after) {
|
||||||
chunk.push(value);
|
chunk.push(value);
|
||||||
if (chunk.length === chunkSize) {
|
if (chunk.length === chunkSize) {
|
||||||
yield chunk;
|
yield chunk;
|
||||||
@@ -144,7 +160,7 @@ export class SQLCachedCollection<
|
|||||||
|
|
||||||
*stream(): IterableIterator<T> {
|
*stream(): IterableIterator<T> {
|
||||||
for (const [_key, value] of this.cache) {
|
for (const [_key, value] of this.cache) {
|
||||||
if (!value.deleted) yield value as T;
|
if (value && !value.deleted) yield value as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
SQLiteItem,
|
SQLiteItem,
|
||||||
isFalse
|
isFalse
|
||||||
} from ".";
|
} from ".";
|
||||||
import { ExpressionOrFactory, SelectQueryBuilder, SqlBool } from "kysely";
|
import { ExpressionOrFactory, SelectQueryBuilder, SqlBool, sql } from "kysely";
|
||||||
import { VirtualizedGrouping } from "../utils/virtualized-grouping";
|
import { VirtualizedGrouping } from "../utils/virtualized-grouping";
|
||||||
import { groupArray } from "../utils/grouping";
|
import { groupArray } from "../utils/grouping";
|
||||||
|
|
||||||
@@ -165,13 +165,13 @@ export class SQLCollection<
|
|||||||
return ids.map((id) => id.id);
|
return ids.map((id) => id.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async items(
|
async records(
|
||||||
ids: string[]
|
ids: string[]
|
||||||
): Promise<Record<string, MaybeDeletedItem<T> | undefined>> {
|
): Promise<Record<string, MaybeDeletedItem<T> | undefined>> {
|
||||||
const results = await this.db()
|
const results = await this.db()
|
||||||
.selectFrom<keyof DatabaseSchema>(this.type)
|
.selectFrom<keyof DatabaseSchema>(this.type)
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("id", "in", ids)
|
.$if(ids.length > 0, (eb) => eb.where("id", "in", ids))
|
||||||
.execute();
|
.execute();
|
||||||
const items: Record<string, MaybeDeletedItem<T>> = {};
|
const items: Record<string, MaybeDeletedItem<T>> = {};
|
||||||
for (const item of results) {
|
for (const item of results) {
|
||||||
@@ -229,9 +229,10 @@ export class SQLCollection<
|
|||||||
selector: (
|
selector: (
|
||||||
qb: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>
|
qb: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>
|
||||||
) => SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>,
|
) => SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>,
|
||||||
batchSize = 50
|
batchSize?: number
|
||||||
) {
|
) {
|
||||||
return new FilteredSelector<T>(
|
return new FilteredSelector<T>(
|
||||||
|
this.type,
|
||||||
this.db().selectFrom<keyof DatabaseSchema>(this.type).$call(selector),
|
this.db().selectFrom<keyof DatabaseSchema>(this.type).$call(selector),
|
||||||
batchSize
|
batchSize
|
||||||
);
|
);
|
||||||
@@ -240,12 +241,13 @@ export class SQLCollection<
|
|||||||
|
|
||||||
export class FilteredSelector<T extends Item> {
|
export class FilteredSelector<T extends Item> {
|
||||||
constructor(
|
constructor(
|
||||||
|
readonly type: keyof DatabaseSchema,
|
||||||
readonly filter: SelectQueryBuilder<
|
readonly filter: SelectQueryBuilder<
|
||||||
DatabaseSchema,
|
DatabaseSchema,
|
||||||
keyof DatabaseSchema,
|
keyof DatabaseSchema,
|
||||||
unknown
|
unknown
|
||||||
>,
|
>,
|
||||||
readonly batchSize: number
|
readonly batchSize: number = 500
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ids(sortOptions?: GroupOptions) {
|
async ids(sortOptions?: GroupOptions) {
|
||||||
@@ -269,6 +271,15 @@ export class FilteredSelector<T extends Item> {
|
|||||||
.execute()) as T[];
|
.execute()) as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async records(ids?: string[], sortOptions?: GroupOptions) {
|
||||||
|
const results = await this.items(ids, sortOptions);
|
||||||
|
const items: Record<string, T> = {};
|
||||||
|
for (const item of results) {
|
||||||
|
items[item.id] = item as T;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
async has(id: string) {
|
async has(id: string) {
|
||||||
const { count } =
|
const { count } =
|
||||||
(await this.filter
|
(await this.filter
|
||||||
@@ -307,7 +318,14 @@ export class FilteredSelector<T extends Item> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async grouped(options: GroupOptions) {
|
async grouped(options: GroupOptions) {
|
||||||
const ids = await this.ids(options);
|
console.time("getting items");
|
||||||
|
const items = await this.filter
|
||||||
|
.$call(this.buildSortExpression(options))
|
||||||
|
.select(["id", options.sortBy, "type"])
|
||||||
|
.execute();
|
||||||
|
console.timeEnd("getting items");
|
||||||
|
console.log(items.length);
|
||||||
|
const ids = groupArray(items, options);
|
||||||
return new VirtualizedGrouping<T>(
|
return new VirtualizedGrouping<T>(
|
||||||
ids,
|
ids,
|
||||||
this.batchSize,
|
this.batchSize,
|
||||||
@@ -321,8 +339,8 @@ export class FilteredSelector<T extends Item> {
|
|||||||
items[item.id] = item as T;
|
items[item.id] = item as T;
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
},
|
}
|
||||||
(ids, items) => groupArray(ids, items, options)
|
//(ids, items) => groupArray(ids, items, options)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,9 +349,20 @@ export class FilteredSelector<T extends Item> {
|
|||||||
qb: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, T>
|
qb: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, T>
|
||||||
) => {
|
) => {
|
||||||
return qb
|
return qb
|
||||||
.orderBy("conflicted desc")
|
.$if(this.type === "notes", (eb) => eb.orderBy("conflicted desc"))
|
||||||
.orderBy("pinned desc")
|
.$if(this.type === "notes" || this.type === "notebooks", (eb) =>
|
||||||
.orderBy(options.sortBy, options.sortDirection);
|
eb.orderBy("pinned desc")
|
||||||
|
)
|
||||||
|
.$if(options.sortBy === "title", (eb) =>
|
||||||
|
eb.orderBy(
|
||||||
|
sql`${sql.raw(options.sortBy)} COLLATE NOCASE ${sql.raw(
|
||||||
|
options.sortDirection
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.$if(options.sortBy !== "title", (eb) =>
|
||||||
|
eb.orderBy(options.sortBy, options.sortDirection)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
packages/core/src/index.ts
Normal file
21
packages/core/src/index.ts
Normal 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export { VirtualizedGrouping } from "./utils/virtualized-grouping";
|
||||||
@@ -39,6 +39,8 @@ export type GroupingKey =
|
|||||||
| "reminders";
|
| "reminders";
|
||||||
|
|
||||||
export type ValueOf<T> = T[keyof T];
|
export type ValueOf<T> = T[keyof T];
|
||||||
|
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||||
|
export type RequiredBy<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>; // Pick<, K> & Omit<T, K>;
|
||||||
|
|
||||||
export type GroupHeader = {
|
export type GroupHeader = {
|
||||||
type: "header";
|
type: "header";
|
||||||
@@ -443,6 +445,6 @@ export function isTrashItem(item: MaybeDeletedItem<Item>): item is TrashItem {
|
|||||||
return !isDeleted(item) && item.type === "trash";
|
return !isDeleted(item) && item.type === "trash";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGroupHeader(item: GroupHeader | Item): item is GroupHeader {
|
export function isGroupHeader(item: any): item is GroupHeader {
|
||||||
return item.type === "header";
|
return item.type === "header";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test, vi } from "vitest";
|
import { test, vi } from "vitest";
|
||||||
import { VirtualizedGrouping } from "../virtualized-grouping";
|
import { VirtualizedGrouping } from "../virtualized-grouping";
|
||||||
|
|
||||||
function item<T>(value: T) {
|
function item<T>(value: T) {
|
||||||
@@ -123,40 +123,3 @@ test("reloading ids should clear all cached batches", async (t) => {
|
|||||||
t.expect(await grouping.item("1")).toStrictEqual(item("1"));
|
t.expect(await grouping.item("1")).toStrictEqual(item("1"));
|
||||||
t.expect(mocked).toHaveBeenLastCalledWith(["1", "2", "3"]);
|
t.expect(mocked).toHaveBeenLastCalledWith(["1", "2", "3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("merge groups if last & first groups are the same (sequential)", async (t) => {
|
|
||||||
const mocked = createMock();
|
|
||||||
const grouping = new VirtualizedGrouping<string>(
|
|
||||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
|
|
||||||
3,
|
|
||||||
mocked,
|
|
||||||
(ids) => [{ title: "Hello", id: ids[0] }]
|
|
||||||
);
|
|
||||||
expect((await grouping.item("1"))?.group?.title).toBe("Hello");
|
|
||||||
expect((await grouping.item("4"))?.group).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("merge groups if last & first groups are the same (random)", async (t) => {
|
|
||||||
const mocked = createMock();
|
|
||||||
const grouping = new VirtualizedGrouping<string>(
|
|
||||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
|
|
||||||
3,
|
|
||||||
mocked,
|
|
||||||
(ids) => [{ title: "Hello", id: ids[0] }]
|
|
||||||
);
|
|
||||||
expect((await grouping.item("1"))?.group?.title).toBe("Hello");
|
|
||||||
expect((await grouping.item("7"))?.group).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("merge groups if last & first groups are the same (reverse)", async (t) => {
|
|
||||||
const mocked = createMock();
|
|
||||||
const grouping = new VirtualizedGrouping<string>(
|
|
||||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
|
|
||||||
3,
|
|
||||||
mocked,
|
|
||||||
(ids) => [{ title: "Hello", id: ids[0] }]
|
|
||||||
);
|
|
||||||
expect((await grouping.item("7"))?.group?.title).toBe("Hello");
|
|
||||||
expect((await grouping.item("1"))?.group?.title).toBe("Hello");
|
|
||||||
expect((await grouping.item("7"))?.group).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -18,15 +18,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isReminderActive } from "../collections/reminders";
|
import { isReminderActive } from "../collections/reminders";
|
||||||
import { GroupOptions, Item } from "../types";
|
import { GroupHeader, GroupOptions, ItemType } from "../types";
|
||||||
import { getWeekGroupFromTimestamp, MONTHS_FULL } from "./date";
|
import { getWeekGroupFromTimestamp, MONTHS_FULL } from "./date";
|
||||||
import { VirtualizedGroupHeader } from "./virtualized-grouping";
|
|
||||||
|
|
||||||
|
type PartialGroupableItem = {
|
||||||
|
id: string;
|
||||||
|
type?: ItemType | null;
|
||||||
|
dateDeleted?: number | null;
|
||||||
|
title?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
dateEdited?: number | null;
|
||||||
|
dateCreated?: number | null;
|
||||||
|
};
|
||||||
type EvaluateKeyFunction<T> = (item: T) => string;
|
type EvaluateKeyFunction<T> = (item: T) => string;
|
||||||
|
|
||||||
export const getSortValue = <T extends Item>(
|
export const getSortValue = (
|
||||||
options: GroupOptions,
|
options: GroupOptions,
|
||||||
item: T
|
item: PartialGroupableItem
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
options.sortBy === "dateDeleted" &&
|
options.sortBy === "dateDeleted" &&
|
||||||
@@ -43,18 +51,20 @@ export const getSortValue = <T extends Item>(
|
|||||||
const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24;
|
const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24;
|
||||||
const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7;
|
const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7;
|
||||||
|
|
||||||
function getKeySelector(options: GroupOptions): EvaluateKeyFunction<Item> {
|
function getKeySelector(
|
||||||
return (item: Item) => {
|
options: GroupOptions
|
||||||
|
): EvaluateKeyFunction<PartialGroupableItem> {
|
||||||
|
return (item) => {
|
||||||
if ("pinned" in item && item.pinned) return "Pinned";
|
if ("pinned" in item && item.pinned) return "Pinned";
|
||||||
else if ("conflicted" in item && item.conflicted) return "Conflicted";
|
else if ("conflicted" in item && item.conflicted) return "Conflicted";
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
if (item.type === "reminder")
|
if (item.type === "reminder")
|
||||||
return isReminderActive(item) ? "Active" : "Inactive";
|
return "Active"; // isReminderActive(item) ? "Active" : "Inactive";
|
||||||
else if (options.sortBy === "title")
|
else if (options.sortBy === "title")
|
||||||
return getFirstCharacter(getTitle(item));
|
return getFirstCharacter(getTitle(item));
|
||||||
else {
|
else {
|
||||||
const value = getSortValue(options, item);
|
const value = getSortValue(options, item) || 0;
|
||||||
switch (options.groupBy) {
|
switch (options.groupBy) {
|
||||||
case "none":
|
case "none":
|
||||||
return "All";
|
return "All";
|
||||||
@@ -80,36 +90,42 @@ function getKeySelector(options: GroupOptions): EvaluateKeyFunction<Item> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function groupArray(
|
export function groupArray(
|
||||||
ids: string[],
|
items: PartialGroupableItem[],
|
||||||
items: Record<string, Item>,
|
|
||||||
options: GroupOptions = {
|
options: GroupOptions = {
|
||||||
groupBy: "default",
|
groupBy: "default",
|
||||||
sortBy: "dateEdited",
|
sortBy: "dateEdited",
|
||||||
sortDirection: "desc"
|
sortDirection: "desc"
|
||||||
}
|
}
|
||||||
): VirtualizedGroupHeader[] {
|
): (string | GroupHeader)[] {
|
||||||
const groups = new Map<string, VirtualizedGroupHeader>([
|
const groups = new Map<string, string[]>([
|
||||||
["Conflicted", { title: "Conflicted", id: "" }],
|
["Conflicted", []],
|
||||||
["Pinned", { title: "Pinned", id: "" }],
|
["Pinned", []]
|
||||||
["Active", { title: "Active", id: "" }],
|
|
||||||
["Inactive", { title: "Inactive", id: "" }]
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const keySelector = getKeySelector(options);
|
const keySelector = getKeySelector(options);
|
||||||
for (const id of ids) {
|
for (const item of items) {
|
||||||
const item = items[id];
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
const groupTitle = keySelector(item);
|
const groupTitle = keySelector(item);
|
||||||
const group = groups.get(groupTitle) || {
|
const group = groups.get(groupTitle) || [];
|
||||||
title: groupTitle,
|
group.push(item.id);
|
||||||
id: ""
|
|
||||||
};
|
|
||||||
if (group.id === "") group.id = id;
|
|
||||||
groups.set(groupTitle, group);
|
groups.set(groupTitle, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.values());
|
return flattenGroups(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenGroups(groups: Map<string, string[]>) {
|
||||||
|
const items: (string | GroupHeader)[] = [];
|
||||||
|
groups.forEach((groupItems, groupTitle) => {
|
||||||
|
if (groupItems.length <= 0) return;
|
||||||
|
items.push({
|
||||||
|
title: groupTitle,
|
||||||
|
id: groupTitle.toLowerCase(),
|
||||||
|
type: "header"
|
||||||
|
});
|
||||||
|
items.push(...groupItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstCharacter(str: string) {
|
function getFirstCharacter(str: string) {
|
||||||
@@ -119,10 +135,6 @@ function getFirstCharacter(str: string) {
|
|||||||
return str[0].toUpperCase();
|
return str[0].toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTitle(item: Item): string {
|
function getTitle(item: PartialGroupableItem): string {
|
||||||
return item.type === "attachment"
|
return item.filename || item.title || "Unknown";
|
||||||
? item.filename
|
|
||||||
: "title" in item
|
|
||||||
? item.title
|
|
||||||
: "Unknown";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,14 @@ export function createObjectId(date = Date.now()): string {
|
|||||||
function swap16(val: number) {
|
function swap16(val: number) {
|
||||||
return ((val & 0xff) << 16) | (val & 0xff00) | ((val >> 16) & 0xff);
|
return ((val & 0xff) << 16) | (val & 0xff00) | ((val >> 16) & 0xff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getObjectIdTimestamp(id: string) {
|
||||||
|
const timestamp = new Date();
|
||||||
|
const time =
|
||||||
|
id.charCodeAt(3) |
|
||||||
|
(id.charCodeAt(2) << 8) |
|
||||||
|
(id.charCodeAt(1) << 16) |
|
||||||
|
(id.charCodeAt(0) << 24);
|
||||||
|
timestamp.setTime(Math.floor(time) * 1000);
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,51 +17,57 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type VirtualizedGroupHeader = {
|
import { GroupHeader, isGroupHeader } from "../types";
|
||||||
title: string;
|
|
||||||
id: string;
|
type BatchOperator<T> = (
|
||||||
};
|
ids: string[],
|
||||||
|
items: Record<string, T>
|
||||||
|
) => Promise<Record<string, unknown>>;
|
||||||
|
type Batch<T> = { items: Record<string, T>; data?: Record<string, unknown> };
|
||||||
export class VirtualizedGrouping<T> {
|
export class VirtualizedGrouping<T> {
|
||||||
private cache: Map<number, Record<string, T>> = new Map();
|
private cache: Map<number, Batch<T>> = new Map();
|
||||||
private groups: Map<number, VirtualizedGroupHeader[]> = new Map();
|
private pending: Map<number, Promise<Batch<T>>> = new Map();
|
||||||
|
groups: GroupHeader[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public ids: string[],
|
public ids: (string | GroupHeader)[],
|
||||||
private readonly batchSize: number,
|
private readonly batchSize: number,
|
||||||
private readonly fetchItems: (ids: string[]) => Promise<Record<string, T>>,
|
private readonly fetchItems: (ids: string[]) => Promise<Record<string, T>>
|
||||||
private readonly groupItems: (
|
|
||||||
ids: string[],
|
|
||||||
items: Record<string, T>
|
|
||||||
) => VirtualizedGroupHeader[] = () => []
|
|
||||||
) {
|
) {
|
||||||
this.ids = ids;
|
this.ids = ids;
|
||||||
|
this.groups = ids.filter((i) => isGroupHeader(i)) as GroupHeader[];
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(index: number) {
|
||||||
|
const item = this.ids[index];
|
||||||
|
if (isGroupHeader(item)) return item.id;
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get item from cache or request the appropriate batch for caching
|
* Get item from cache or request the appropriate batch for caching
|
||||||
* and load it from there.
|
* and load it from there.
|
||||||
*/
|
*/
|
||||||
async item(id: string) {
|
item(id: string): Promise<T | undefined>;
|
||||||
|
item(
|
||||||
|
id: string,
|
||||||
|
operate: BatchOperator<T>
|
||||||
|
): Promise<{ item: T; data: unknown } | undefined>;
|
||||||
|
async item(id: string, operate?: BatchOperator<T>) {
|
||||||
const index = this.ids.indexOf(id);
|
const index = this.ids.indexOf(id);
|
||||||
if (index <= -1) return;
|
if (index <= -1) return;
|
||||||
|
|
||||||
const batchIndex = Math.floor(index / this.batchSize);
|
const batchIndex = Math.floor(index / this.batchSize);
|
||||||
const batch = this.cache.get(batchIndex) || (await this.load(batchIndex));
|
const { items, data } =
|
||||||
const groups = this.groups.get(batchIndex);
|
this.cache.get(batchIndex) || (await this.loadBatch(batchIndex, operate));
|
||||||
|
|
||||||
const group = groups?.find((g) => g.id === id);
|
return operate ? { item: items[id], data: data?.[id] } : items[id];
|
||||||
if (group)
|
|
||||||
return {
|
|
||||||
group: { type: "header", id: group.title, title: group.title },
|
|
||||||
item: batch[id]
|
|
||||||
};
|
|
||||||
return { item: batch[id] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload the cache
|
* Reload the cache
|
||||||
*/
|
*/
|
||||||
refresh(ids: string[]) {
|
refresh(ids: (string | GroupHeader)[]) {
|
||||||
this.ids = ids;
|
this.ids = ids;
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
@@ -70,42 +76,40 @@ export class VirtualizedGrouping<T> {
|
|||||||
*
|
*
|
||||||
* @param index
|
* @param index
|
||||||
*/
|
*/
|
||||||
private async load(batch: number) {
|
private async load(batchIndex: number, operate?: BatchOperator<T>) {
|
||||||
const start = batch * this.batchSize;
|
const start = batchIndex * this.batchSize;
|
||||||
const end = start + this.batchSize;
|
const end = start + this.batchSize;
|
||||||
const batchIds = this.ids.slice(start, end);
|
const batchIds = this.ids
|
||||||
|
.slice(start, end)
|
||||||
|
.filter((id) => typeof id === "string") as string[];
|
||||||
const items = await this.fetchItems(batchIds);
|
const items = await this.fetchItems(batchIds);
|
||||||
const groups = this.groupItems(batchIds, items);
|
console.time("operate");
|
||||||
|
const batch = {
|
||||||
const lastBatchIndex = this.last;
|
items,
|
||||||
const prevGroups = this.groups.get(lastBatchIndex);
|
data: operate ? await operate(batchIds, items) : undefined
|
||||||
if (prevGroups && prevGroups.length > 0 && groups.length > 0) {
|
};
|
||||||
const lastGroup = prevGroups[prevGroups.length - 1];
|
console.timeEnd("operate");
|
||||||
if (lastGroup.title === groups[0].title) {
|
this.cache.set(batchIndex, batch);
|
||||||
// if user is moving downwards, we remove the last group from the
|
|
||||||
// current batch, otherwise we remove the first group from the previous
|
|
||||||
// batch.
|
|
||||||
lastBatchIndex < batch ? groups.pop() : prevGroups.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(batch, items);
|
|
||||||
this.groups.set(batch, groups);
|
|
||||||
this.clear();
|
this.clear();
|
||||||
return items;
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBatch(batch: number, operate?: BatchOperator<T>) {
|
||||||
|
if (this.pending.has(batch)) return this.pending.get(batch)!;
|
||||||
|
console.time("loading batch");
|
||||||
|
const promise = this.load(batch, operate);
|
||||||
|
this.pending.set(batch, promise);
|
||||||
|
return promise.finally(() => {
|
||||||
|
console.timeEnd("loading batch");
|
||||||
|
this.pending.delete(batch);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private clear() {
|
private clear() {
|
||||||
if (this.cache.size <= 2) return;
|
if (this.cache.size <= 2) return;
|
||||||
for (const [key] of this.cache) {
|
for (const [key] of this.cache) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
this.groups.delete(key);
|
|
||||||
if (this.cache.size === 2) break;
|
if (this.cache.size === 2) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get last() {
|
|
||||||
const keys = Array.from(this.cache.keys());
|
|
||||||
return keys[keys.length - 1];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user