Files
notesnook/packages/core/collections/notes.js
Abdullah Atta 201366b39e core: get rid of noteIds in notebook topics
This is a BREAKING change in the core & will require updating the
clients. The way it works is instead of keeping noteIds of all the
notes in the topic, it keeps an in-memory cache. This in-memory
cache `topicReferences` lives in the notes collection & decouples
notes from notebooks/topics. This also simplifies all the different
actions where references would persist after the note was deleted.
Since the note acts as the source of truth for where it currently is,
there is nothing else to do except rebuild the `topicReferences`
cache.
2022-09-08 13:39:06 +05:00

445 lines
11 KiB
JavaScript

/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 Collection from "./collection";
import Note from "../models/note";
import getId from "../utils/id";
import { getContentFromData } from "../content-types";
import qclone from "qclone/src/qclone";
import { deleteItem, findById } from "../utils/array";
/**
* @typedef {{ id: string, topic: string, rebuildCache?: boolean }} NotebookReference
*/
export default class Notes extends Collection {
constructor(db, name, cached) {
super(db, name, cached);
this.topicReferences = new NoteIdCache(this._db);
}
async init() {
await super.init();
this.topicReferences.rebuild();
}
async merge(remoteNote) {
if (!remoteNote) return;
const id = remoteNote.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 || []) {
await this._db.tags.untag(tag, id);
}
}
if (remoteNote.deleted) return await this._collection.addItem(remoteNote);
await this._resolveColorAndTags(remoteNote);
return await this._collection.addItem(remoteNote);
}
async add(noteArg) {
if (!noteArg) return;
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);
let note = {
...oldNote,
...noteArg
};
if (oldNote) note.contentId = oldNote.contentId;
if (!oldNote && !noteArg.content && !noteArg.contentId && !noteArg.title)
return;
if (noteArg.content && noteArg.content.data && noteArg.content.type) {
const { type, data } = noteArg.content;
let content = getContentFromData(type, data);
if (!content) throw new Error("Invalid content type.");
note.contentId = await this._db.content.add({
noteId: id,
sessionId: note.sessionId,
id: note.contentId,
type,
data,
localOnly: !!note.localOnly
});
note.headline = getNoteHeadline(note, content);
if (oldNote) note.dateEdited = Date.now();
}
if (noteArg.localOnly !== undefined) {
await this._db.content.add({
id: note.contentId,
localOnly: !!noteArg.localOnly
});
}
const noteTitle = getNoteTitle(note, oldNote);
if (oldNote && oldNote.title !== noteTitle) note.dateEdited = Date.now();
note = {
id,
contentId: note.contentId,
type: "note",
title: noteTitle,
headline: note.headline,
tags: note.tags || [],
notebooks: note.notebooks || undefined,
color: note.color,
pinned: !!note.pinned,
locked: !!note.locked,
favorite: !!note.favorite,
localOnly: !!note.localOnly,
conflicted: !!note.conflicted,
readonly: !!note.readonly,
dateCreated: note.dateCreated,
dateEdited: note.dateEdited || note.dateCreated || Date.now(),
dateModified: note.dateModified
};
await this._collection.addItem(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) {
if (!id) return;
let note = id.type ? id : this._collection.getItem(id);
if (!note || note.deleted) return;
return new Note(note, this._db);
}
get raw() {
return this._collection.getRaw();
}
get all() {
const items = this._collection.getItems();
return items;
}
get pinned() {
return this.all.filter((item) => item.pinned === true);
}
get conflicted() {
return this.all.filter((item) => item.conflicted === true);
}
get favorites() {
return this.all.filter((item) => item.favorite === true);
}
get deleted() {
return this.raw.filter((item) => item.dateDeleted > 0);
}
get locked() {
return this.all.filter((item) => item.locked === true);
}
tagged(tagId) {
return this._getTagItems(tagId, "tags");
}
colored(colorId) {
return this._getTagItems(colorId, "colors");
}
exists(id) {
return this._collection.exists(id);
}
/**
* @private
*/
_getTagItems(tagId, collection) {
const tag = this._db[collection].tag(tagId);
if (!tag || tag.noteIds.length <= 0) return [];
const array = tag.noteIds.reduce((arr, id) => {
const item = this.note(id);
if (item) arr.push(item.data);
return arr;
}, []);
return array.sort((a, b) => b.dateCreated - a.dateCreated);
}
delete(...ids) {
return this._delete(true, ...ids);
}
remove(...ids) {
return this._delete(false, ...ids);
}
/**
* @private
*/
async _delete(moveToTrash = true, ...ids) {
for (let id of ids) {
let item = this.note(id);
if (!item) continue;
const itemData = qclone(item.data);
if (itemData.notebooks && !moveToTrash) {
for (let notebook of itemData.notebooks) {
for (let topicId of notebook.topics) {
await this.removeFromNotebook(
{ id: notebook.id, topic: topicId, rebuildCache: false },
id
);
}
}
}
for (let tag of itemData.tags) {
await this._db.tags.untag(tag, id);
}
if (itemData.color) {
await this._db.colors.untag(itemData.color, id);
}
const attachments = this._db.attachments.ofNote(itemData.id, "all");
for (let attachment of attachments) {
await this._db.attachments.delete(
attachment.metadata.hash,
itemData.id
);
}
// await this._collection.removeItem(id);
if (moveToTrash) await this._db.trash.add(itemData);
else {
await this._collection.removeItem(id);
await this._db.content.remove(itemData.contentId);
}
}
this.topicReferences.rebuild();
}
async _resolveColorAndTags(note) {
const { color, tags, id } = note;
if (color) {
const { title } = await this._db.colors.add(color, id);
note.color = title;
}
if (tags && tags.length) {
for (let i = 0; i < tags.length; ++i) {
const tag = tags[i];
const addedTag = await this._db.tags.add(tag, id).catch(() => void 0);
if (!addedTag) {
tags.splice(i, 1);
continue;
}
if (addedTag.title !== tag) tags[i] = addedTag.title;
}
}
}
/**
* @param {NotebookReference} to
*/
async addToNotebook(to, ...noteIds) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id || !to.topic)
throw new Error(
"The destination notebook must contain notebookId and topic."
);
const { id: notebookId, topic: topicId } = to;
for (let noteId of noteIds) {
let note = this._db.notes.note(noteId);
if (!note || note.data.deleted) continue;
const notebooks = note.notebooks || [];
const noteNotebook = notebooks.find((nb) => nb.id === notebookId);
const noteHasNotebook = !!noteNotebook;
const noteHasTopic =
noteHasNotebook && noteNotebook.topics.indexOf(topicId) > -1;
if (noteHasNotebook && !noteHasTopic) {
// 1 note can be inside multiple topics
noteNotebook.topics.push(topicId);
} else if (!noteHasNotebook) {
notebooks.push({
id: notebookId,
topics: [topicId]
});
}
if (!noteHasNotebook || !noteHasTopic) {
await this._db.notes.add({
id: noteId,
notebooks
});
this.topicReferences.add(topicId, noteId);
}
}
}
/**
* @param {NotebookReference} to
*/
async removeFromNotebook(to, ...noteIds) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id || !to.topic)
throw new Error(
"The destination notebook must contain notebookId and topic."
);
const { id: notebookId, topic: topicId, rebuildCache = true } = to;
for (const noteId of noteIds) {
const note = this.note(noteId);
if (!note || note.deleted || !note.notebooks) {
continue;
}
const { notebooks } = note;
const notebook = findById(notebooks, notebookId);
if (!notebook) continue;
const { topics } = notebook;
if (!deleteItem(topics, topicId)) continue;
if (topics.length <= 0) deleteItem(notebooks, notebook);
await this._db.notes.add({
id: noteId,
notebooks
});
}
if (rebuildCache) this.topicReferences.rebuild();
}
async _clearAllNotebookReferences(notebookId) {
const notes = this._db.notes.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) continue;
for (let notebook of notebooks) {
if (notebook.id !== notebookId) continue;
if (!deleteItem(notebooks, notebook)) continue;
}
await this._collection.updateItem(note);
}
}
}
function getNoteHeadline(note, content) {
if (note.locked) return "";
return content.toHeadline();
}
const NEWLINE_STRIP_REGEX = /[\r\n\t\v]+/gm;
function getNoteTitle(note, oldNote) {
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) {
return oldNote.title.replace(NEWLINE_STRIP_REGEX, " ");
}
return `Note ${new Date().toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short"
})}`;
}
class NoteIdCache {
/**
*
* @param {import("../api/index").default} db
*/
constructor(db) {
this._db = db;
this.cache = new Map();
}
rebuild() {
this.cache = new Map();
const notes = this._db.notes.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) continue;
for (let notebook of notebooks) {
for (let topic of notebook.topics) {
this.add(topic, note.id);
}
}
}
}
add(topicId, noteId) {
let noteIds = this.cache.get(topicId);
if (!noteIds) noteIds = [];
if (noteIds.includes(noteId)) return;
noteIds.push(noteId);
this.cache.set(topicId, noteIds);
}
has(topicId, noteId) {
let noteIds = this.cache.get(topicId);
if (!noteIds) return false;
return noteIds.includes(noteId);
}
count(topicId) {
let noteIds = this.cache.get(topicId);
if (!noteIds) return 0;
return noteIds.length;
}
get(topicId) {
return this.cache.get(topicId) || [];
}
}