/* 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 . */ 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) || []; } }