core: get rid of topics; move to sub notebooks

This commit is contained in:
Abdullah Atta
2023-09-15 20:25:04 +05:00
parent cc60558839
commit 79b75c5ba7
21 changed files with 293 additions and 962 deletions

View File

@@ -166,9 +166,7 @@ describe.each([
db.notebooks.all.every((v) => v.title != null && v.dateModified > 0)
).toBeTruthy();
expect(
db.notebooks.all.every((v) => v.topics.every((topic) => !topic.notes))
).toBeTruthy();
expect(db.notebooks.all.every((v) => !v.topics)).toBeTruthy();
expect(
db.attachments.all.every((v) => v.dateModified > 0 && !v.dateEdited)

View File

@@ -64,10 +64,3 @@ test("search notebooks", () =>
let filtered = db.lookup.notebooks(db.notebooks.all, "Description");
expect(filtered.length).toBeGreaterThan(0);
}));
test("search topics", () =>
notebookTest().then(async ({ db, id }) => {
const topics = db.notebooks.topics(id).all;
let filtered = db.lookup.topics(topics, "hello");
expect(filtered).toHaveLength(1);
}));

View File

@@ -17,10 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { notebookTest, TEST_NOTEBOOK, TEST_NOTE, delay } from "./utils";
import { makeTopic } from "../src/collections/topics";
import { notebookTest, TEST_NOTEBOOK } from "./utils";
import { test, expect } from "vitest";
import qclone from "qclone";
test("add a notebook", () =>
notebookTest().then(({ db, id }) => {
@@ -58,135 +56,3 @@ test("updating notebook with empty title should throw", () =>
notebookTest().then(async ({ db, id }) => {
await expect(db.notebooks.add({ id, title: "" })).rejects.toThrow();
}));
test("merge notebook with new topics", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
const newNotebook = db.notebooks.merge(notebook.data, {
...notebook.data,
topics: [...notebook.data.topics, makeTopic({ title: "Home" }, id)],
remote: true
});
expect(
newNotebook.topics.findIndex((t) => t.title === "Home")
).toBeGreaterThanOrEqual(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello")
).toBeGreaterThanOrEqual(0);
}));
test("merge notebook with topics removed", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
const newNotebook = db.notebooks.merge(notebook.data, {
...notebook.data,
topics: [makeTopic({ title: "Home" }, id)],
remote: true
});
expect(
newNotebook.topics.findIndex((t) => t.title === "Home")
).toBeGreaterThanOrEqual(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello")
).toBeLessThan(0);
}));
test("merge notebook with topic edited", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
const newNotebook = db.notebooks.merge(notebook.data, {
...notebook.data,
topics: [{ ...notebook.data.topics[0], title: "hello (edited)" }],
remote: true
});
expect(
newNotebook.topics.findIndex((t) => t.title === "hello (edited)")
).toBeGreaterThanOrEqual(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello")
).toBeLessThan(0);
}));
test("merge notebook when local notebook is also edited", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
let newNotebook = { ...qclone(notebook.data), remote: true };
newNotebook.topics[0].title = "hello (edited)";
await delay(500);
await notebook.topics.add({
...notebook.topics.all[0],
title: "hello (edited too)"
});
newNotebook = db.notebooks.merge(
db.notebooks.notebook(id).data,
newNotebook,
0
);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello (edited too)")
).toBeGreaterThanOrEqual(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello (edited)")
).toBeLessThan(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello")
).toBeLessThan(0);
}));
test("merging notebook when local notebook is not edited should not update remote notebook dateEdited", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
let note = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook(
{ id: notebook.data.id, topic: notebook.data.topics[0].id },
note
);
const newNotebook = db.notebooks.merge(notebook.data, {
...notebook.data,
remote: true
});
expect(db.notebooks.notebook(id).dateEdited).toBe(newNotebook.dateEdited);
}));
test("merge notebook with topic removed that is edited in the local notebook", () =>
notebookTest().then(async ({ db, id }) => {
let notebook = db.notebooks.notebook(id);
let newNotebook = { ...qclone(notebook.data), remote: true };
newNotebook.topics.splice(0, 1); // remove hello topic
const lastSynced = Date.now();
await delay(500);
await notebook.topics.add({
...notebook.topics.all[1],
title: "hello (i exist)"
});
newNotebook = db.notebooks.merge(
db.notebooks.notebook(id).data,
newNotebook,
lastSynced
);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello (i exist)")
).toBeGreaterThanOrEqual(0);
expect(
newNotebook.topics.findIndex((t) => t.title === "hello")
).toBeLessThan(0);
}));

View File

@@ -35,35 +35,37 @@ async function createAndAddNoteToNotebook(
noteId: string,
options: {
notebookTitle: string;
topicTitle: string;
subNotebookTitle: string;
}
) {
const { notebookTitle, topicTitle } = options;
const { notebookTitle, subNotebookTitle } = options;
const notebookId = await db.notebooks.add({ title: notebookTitle });
if (!notebookId) throw new Error("Could not create notebook");
const topics = db.notebooks.topics(notebookId);
await topics.add({ title: topicTitle });
const subNotebookId = await db.notebooks.add({ title: subNotebookTitle });
if (!subNotebookId) throw new Error("Could not create sub notebook");
const topic = topics.topic(topicTitle);
if (!topic) throw new Error("Could not find topic.");
await db.notes.addToNotebook({ id: notebookId, topic: topic.id }, noteId);
await db.relations.add(
{ type: "notebook", id: notebookId },
{ type: "notebook", id: subNotebookId }
);
return { topic, topics, notebookId };
await db.notes.addToNotebook(subNotebookId, noteId);
return { subNotebookId, notebookId };
}
test("add invalid note", () =>
databaseTest().then(async (db) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let id = await db.notes.add();
expect(id).toBeUndefined();
id = await db.notes.add({});
expect(id).toBeUndefined();
expect(db.notes.add()).rejects.toThrow();
expect(db.notes.add({})).rejects.toThrow();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
id = await db.notes.add({ hello: "world" });
expect(id).toBeUndefined();
expect(db.notes.add({ hello: "world" })).rejects.toThrow();
}));
test("add note", () =>
@@ -84,23 +86,21 @@ test("delete note", () =>
const notebookId = await db.notebooks.add(TEST_NOTEBOOK);
if (!notebookId) throw new Error("Could not create notebook.");
const topics = db.notebooks.topics(notebookId);
const subNotebookId = await db.notebooks.add({ title: "hello" });
if (!notebookId) throw new Error("Could not create sub notebook.");
let topic = topics.topic("hello");
if (!topic) throw new Error("Could not find topic.");
await db.relations.add(
{ type: "notebook", id: notebookId },
{ type: "notebook", id: subNotebookId }
);
await db.notes.addToNotebook({ id: notebookId, topic: topic.id }, id);
await db.notes.addToNotebook(subNotebookId, id);
topic = topics.topic("hello");
if (!topic) throw new Error("Could not find topic.");
expect(topic.all.findIndex((v) => v.id === id)).toBeGreaterThan(-1);
await db.notes.delete(id);
expect(db.notes.note(id)).toBeUndefined();
expect(topic.all.findIndex((v) => v.id === id)).toBe(-1);
expect(db.notes.note(id)).toBeUndefined();
expect(db.notebooks.totalNotes(notebookId)).toBe(0);
expect(topics.topic("hello")?.totalNotes).toBe(0);
expect(db.notebooks.totalNotes(subNotebookId)).toBe(0);
}));
test("get all notes", () =>
@@ -217,57 +217,67 @@ test("favorite note", () =>
expect(note?.data.favorite).toBe(true);
}));
test("add note to topic", () =>
test("add note to subnotebook", () =>
noteTest().then(async ({ db, id }) => {
const { topic, notebookId } = await createAndAddNoteToNotebook(db, id, {
notebookTitle: "Hello",
topicTitle: "Home"
});
expect(topic.all).toHaveLength(1);
expect(topic.totalNotes).toBe(1);
expect(db.notebooks.totalNotes(notebookId)).toBe(1);
expect(db.notes.note(id)?.notebooks?.some((n) => n.id === notebookId)).toBe(
true
const { subNotebookId, notebookId } = await createAndAddNoteToNotebook(
db,
id,
{
notebookTitle: "Hello",
subNotebookTitle: "Home"
}
);
expect(
db.relations.from({ type: "notebook", id: notebookId }, "notebook")
).toHaveLength(1);
expect(db.notebooks.totalNotes(subNotebookId)).toBe(1);
expect(db.notebooks.totalNotes(notebookId)).toBe(1);
}));
test("duplicate note to topic should not be added", () =>
noteTest().then(async ({ db, id }) => {
const { topics } = await createAndAddNoteToNotebook(db, id, {
const { subNotebookId } = await createAndAddNoteToNotebook(db, id, {
notebookTitle: "Hello",
topicTitle: "Home"
subNotebookTitle: "Home"
});
expect(topics.topic("Home")?.all).toHaveLength(1);
expect(db.notebooks.totalNotes(subNotebookId)).toBe(1);
}));
test("add the same note to 2 notebooks", () =>
noteTest().then(async ({ db, id }) => {
const nb1 = await createAndAddNoteToNotebook(db, id, {
notebookTitle: "Hello",
topicTitle: "Home"
subNotebookTitle: "Home"
});
const nb2 = await createAndAddNoteToNotebook(db, id, {
notebookTitle: "Hello2",
topicTitle: "Home2"
subNotebookTitle: "Home2"
});
expect(nb1.topics.topic(nb1.topic.id)?.has(id)).toBe(true);
expect(nb2.topics.topic(nb2.topic.id)?.has(id)).toBe(true);
expect(db.notes.note(id)?.notebooks).toHaveLength(2);
expect(
db.relations
.from({ type: "notebook", id: nb1.subNotebookId }, "note")
.has(id)
).toBe(true);
expect(
db.relations
.from({ type: "notebook", id: nb2.subNotebookId }, "note")
.has(id)
).toBe(true);
expect(db.relations.to({ type: "note", id }, "notebook")).toHaveLength(2);
}));
test("moving note to same notebook and topic should do nothing", () =>
noteTest().then(async ({ db, id }) => {
const { notebookId, topic } = await createAndAddNoteToNotebook(db, id, {
const { subNotebookId } = await createAndAddNoteToNotebook(db, id, {
notebookTitle: "Home",
topicTitle: "Hello"
subNotebookTitle: "Hello"
});
await db.notes.addToNotebook({ id: notebookId, topic: topic.id }, id);
expect(db.notes.note(id)?.notebooks?.some((n) => n.id === notebookId)).toBe(
true
);
await db.notes.addToNotebook(subNotebookId, id);
expect(db.relations.to({ type: "note", id }, "notebook")).toHaveLength(1);
}));
test("export note to html", () =>
@@ -308,19 +318,14 @@ test("deleting a colored note should remove it from that color", () =>
);
expect(
db.relations
.from({ id: colorId, type: "color" }, "note")
.findIndex((r) => r.to.id === id)
).toBe(0);
db.relations.from({ id: colorId, type: "color" }, "note").has(id)
).toBe(true);
await db.notes.delete(id);
expect(
db.relations
.from({ id: colorId, type: "color" }, "note")
.findIndex((r) => r.to.id === id)
).toBe(-1);
// TODO expect(color.noteIds.indexOf(id)).toBe(-1);
db.relations.from({ id: colorId, type: "color" }, "note").has(id)
).toBe(false);
}));
test("note's content should follow note's localOnly property", () =>
@@ -330,7 +335,7 @@ test("note's content should follow note's localOnly property", () =>
if (!note?.contentId) throw new Error("No content in note.");
expect(note?.data.localOnly).toBe(true);
let content = await db.content.raw(note.contentId);
let content = await db.content.get(note.contentId);
expect(content?.localOnly).toBe(true);
await db.notes.note(id)?.localOnly();
@@ -338,7 +343,7 @@ test("note's content should follow note's localOnly property", () =>
if (!note?.contentId) throw new Error("No content in note.");
expect(note?.data.localOnly).toBe(false);
content = await db.content.raw(note.contentId);
content = await db.content.get(note.contentId);
expect(content?.localOnly).toBe(false);
}));

View File

@@ -43,18 +43,6 @@ test("create a duplicate shortcut of notebook", () =>
expect(db.shortcuts.all[0].item.id).toBe(id);
}));
test("create shortcut of a topic", () =>
notebookTest().then(async ({ db, id }) => {
const notebook = db.notebooks.notebook(id).data;
const topic = notebook.topics[0];
await db.shortcuts.add({
item: { type: "topic", id: topic.id, notebookId: id }
});
expect(db.shortcuts.all).toHaveLength(1);
expect(db.shortcuts.all[0].item.id).toBe(topic.id);
}));
test("pin a tag", () =>
databaseTest().then(async (db) => {
const tagId = await db.tags.add({ title: "HELLO!" });

View File

@@ -1,155 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { delay, notebookTest, TEST_NOTE } from "./utils";
import { test, expect } from "vitest";
test("get empty topic", () =>
notebookTest().then(({ db, id }) => {
let topic = db.notebooks.topics(id).topic("hello");
expect(topic.all).toHaveLength(0);
}));
test("getting invalid topic should return undefined", () =>
notebookTest().then(({ db, id }) => {
expect(db.notebooks.topics(id).topic("invalid")).toBeUndefined();
}));
test("add topic to notebook", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
expect(topics.all.length).toBeGreaterThan(1);
expect(topics.all.findIndex((v) => v.title === "Home")).toBeGreaterThan(-1);
}));
test("add note to topic", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
let topic = topics.topic("hello");
let noteId = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
topic = topics.topic("hello");
expect(topic.totalNotes).toBe(1);
expect(db.notebooks.notebook(id).totalNotes).toBe(1);
}));
test("delete note of a topic", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
let topic = topics.topic("hello");
let noteId = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
topic = topics.topic("hello");
expect(topic.totalNotes).toBe(1);
expect(db.notebooks.notebook(id).totalNotes).toBe(1);
await db.notes.removeFromNotebook({ id, topic: topic.id }, noteId);
topic = topics.topic("hello");
expect(topic.totalNotes).toBe(0);
expect(db.notebooks.notebook(id).totalNotes).toBe(0);
}));
test("edit topic title", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
let topic = topics.topic("Home");
expect(topics.all).toHaveLength(2);
const oldDateEdited = topic._topic.dateEdited;
await delay(30);
await topics.add({ id: topic._topic.id, title: "Hello22" });
expect(topics.all).toHaveLength(2);
expect(topics.topic(topic._topic.id)._topic.title).toBe("Hello22");
expect(topics.topic(topic._topic.id)._topic.dateEdited).toBeGreaterThan(
oldDateEdited
);
}));
test("get topic", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
let topic = topics.topic("Home");
let noteId = await db.notes.add({
content: TEST_NOTE.content
});
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
topic = topics.topic("Home");
expect(await db.content.get(topic.all[0].contentId)).toBeDefined();
expect(topic.totalNotes).toBe(1);
}));
test("delete a topic", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
await topics.delete(topics.topic("Home").id);
expect(topics.all.findIndex((v) => v.title === "Home")).toBe(-1);
}));
test("delete note from edited topic", () =>
notebookTest().then(async ({ db, id }) => {
const noteId = await db.notes.add(TEST_NOTE);
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
let topic = topics.topic("Home");
await db.notes.addToNotebook({ id, topic: topic._topic.title }, noteId);
await topics.add({ id: topic._topic.id, title: "Hello22" });
await db.notes.delete(noteId);
}));
test("editing one topic should not update dateEdited of all", () =>
notebookTest().then(async ({ db, id }) => {
let topics = db.notebooks.topics(id);
await topics.add({ title: "Home" });
await topics.add("Home2");
await topics.add("Home3");
let topic = topics.topic("Home");
const oldTopics = topics.all.filter((t) => t.title !== "Home");
await delay(100);
await topics.add({ id: topic._topic.id, title: "Hello22" });
const newTopics = topics.all.filter((t) => t.title !== "Hello22");
expect(
newTopics.every(
(t) =>
oldTopics.findIndex(
(topic) => topic.id === t.id && topic.dateEdited === t.dateEdited
) > -1
)
).toBe(true);
}));

View File

@@ -34,8 +34,12 @@ test("trash should be empty", () =>
test("permanently delete a note", () =>
databaseTest().then(async (db) => {
const noteId = await db.notes.add({ ...TEST_NOTE, sessionId: Date.now() });
const noteId = await db.notes.add({
...TEST_NOTE,
sessionId: Date.now().toString()
});
const note = db.notes.note(noteId);
if (!note) throw new Error("Could not find note.");
let sessions = await db.noteHistory.get(noteId);
expect(sessions).toHaveLength(1);
@@ -45,7 +49,7 @@ test("permanently delete a note", () =>
expect(await note.content()).toBeDefined();
await db.trash.delete(db.trash.all[0].id);
expect(db.trash.all).toHaveLength(0);
const content = await db.content.get(note.data.contentId);
const content = note.contentId && (await db.content.get(note.contentId));
expect(content).toBeUndefined();
sessions = await db.noteHistory.get(noteId);
@@ -54,25 +58,27 @@ test("permanently delete a note", () =>
test("restore a deleted note that was in a notebook", () =>
noteTest().then(async ({ db, id }) => {
let nbId = await db.notebooks.add(TEST_NOTEBOOK);
const topic = db.notebooks.notebook(nbId).topics.topic("hello");
await db.notes.addToNotebook({ id: nbId, topic: topic.id }, id);
const notebookId = await db.notebooks.add(TEST_NOTEBOOK);
const subNotebookId = await db.notebooks.add({ title: "hello" });
await db.relations.add(
{ type: "notebook", id: notebookId },
{ type: "notebook", id: subNotebookId }
);
await db.notes.addToNotebook(subNotebookId, id);
await db.notes.delete(id);
await db.trash.restore(db.trash.all[0].id);
expect(db.trash.all).toHaveLength(0);
let note = db.notes.note(id);
const note = db.notes.note(id);
expect(note).toBeDefined();
expect(await note.content()).toBe(TEST_NOTE.content.data);
expect(await note?.content()).toBe(TEST_NOTE.content.data);
const notebook = db.notebooks.notebook(nbId);
expect(notebook.topics.topic(topic.id).has(id)).toBe(true);
expect(note.notebooks.some((n) => n.id === nbId)).toBe(true);
expect(notebook.topics.has("hello")).toBeDefined();
expect(
db.relations.from({ type: "notebook", id: subNotebookId }, "note").has(id)
).toBe(true);
}));
test("delete a locked note", () =>
@@ -82,7 +88,9 @@ test("delete a locked note", () =>
await db.vault.add(id);
await db.notes.delete(id);
expect(db.trash.all).toHaveLength(1);
expect(await db.content.get(note.data.contentId)).toBeDefined();
expect(
note && note.contentId && (await db.content.get(note.contentId))
).toBeDefined();
}));
test("restore a deleted locked note", () =>
@@ -92,7 +100,9 @@ test("restore a deleted locked note", () =>
await db.vault.add(id);
await db.notes.delete(id);
expect(db.trash.all).toHaveLength(1);
expect(await db.content.get(note.data.contentId)).toBeDefined();
expect(
note && note.contentId && (await db.content.get(note.contentId))
).toBeDefined();
await db.trash.restore(db.trash.all[0].id);
expect(db.trash.all).toHaveLength(0);
note = db.notes.note(id);
@@ -101,69 +111,64 @@ test("restore a deleted locked note", () =>
test("restore a deleted note that's in a deleted notebook", () =>
noteTest().then(async ({ db, id }) => {
let nbId = await db.notebooks.add(TEST_NOTEBOOK);
const topic = db.notebooks.notebook(nbId).topics.topic("hello");
await db.notes.addToNotebook({ id: nbId, topic: topic.id }, id);
const notebookId = await db.notebooks.add(TEST_NOTEBOOK);
await db.notes.addToNotebook(notebookId, id);
await db.notes.delete(id);
await db.notebooks.delete(nbId);
const deletedNote = db.trash.all.find(
(v) => v.id === id && v.itemType === "note"
);
await db.trash.restore(deletedNote.id);
let note = db.notes.note(id);
await db.notebooks.delete(notebookId);
await db.trash.restore(id);
const note = db.notes.note(id);
expect(note).toBeDefined();
expect(db.notes.note(id).notebook).toBeUndefined();
expect(db.relations.to({ type: "note", id }, "notebook")).toHaveLength(0);
}));
test("delete a notebook", () =>
notebookTest().then(async ({ db, id }) => {
let noteId = await db.notes.add(TEST_NOTE);
let notebook = db.notebooks.notebook(id);
const topic = notebook.topics.topic("hello");
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
const noteId = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook(id, noteId);
await db.notebooks.delete(id);
expect(db.notebooks.notebook(id)).toBeUndefined();
expect(db.notes.note(noteId).notebook).toBeUndefined();
expect(
db.relations.to({ type: "note", id: noteId }, "notebook")
).toHaveLength(0);
}));
test("restore a deleted notebook", () =>
notebookTest().then(async ({ db, id }) => {
let noteId = await db.notes.add(TEST_NOTE);
const topic = db.notebooks.notebook(id).topics.topic("hello");
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
const noteId = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook(id, noteId);
await db.notebooks.delete(id);
await db.trash.restore(id);
let notebook = db.notebooks.notebook(id);
const notebook = db.notebooks.notebook(id);
expect(notebook).toBeDefined();
let note = db.notes.note(noteId);
const noteNotebook = note.notebooks.find((n) => n.id === id);
expect(noteNotebook).toBeDefined();
expect(noteNotebook.topics).toHaveLength(1);
expect(notebook.topics.topic(noteNotebook.topics[0])).toBeDefined();
expect(
db.relations.to({ type: "note", id: noteId }, "notebook")
).toHaveLength(1);
expect(
db.relations.to({ type: "note", id: noteId }, "notebook").has(id)
).toBe(true);
}));
test("restore a notebook that has deleted notes", () =>
notebookTest().then(async ({ db, id }) => {
let noteId = await db.notes.add(TEST_NOTE);
let notebook = db.notebooks.notebook(id);
const topic = notebook.topics.topic("hello");
await db.notes.addToNotebook({ id, topic: topic.id }, noteId);
const noteId = await db.notes.add(TEST_NOTE);
await db.notes.addToNotebook(id, noteId);
await db.notebooks.delete(id);
await db.notes.delete(noteId);
const deletedNotebook = db.trash.all.find(
(v) => v.id === id && v.itemType === "notebook"
);
await db.trash.restore(deletedNotebook.id);
notebook = db.notebooks.notebook(id);
await db.trash.restore(id);
const notebook = db.notebooks.notebook(id);
expect(notebook).toBeDefined();
expect(notebook.topics.topic("hello").has(noteId)).toBe(false);
expect(
db.relations.from({ type: "notebook", id: id }, "note").has(noteId)
).toBe(false);
}));
test("permanently delete items older than 7 days", () =>
@@ -175,16 +180,27 @@ test("permanently delete items older than 7 days", () =>
await db.notebooks.delete(notebookId);
await db.notes.delete(noteId);
const note = db.trash.all.find((t) => t.id === noteId);
if (!note || note.itemType !== "note")
throw new Error("Could not find note in trash.");
await db.notes.collection.update({
...note,
type: "trash",
id: noteId,
dateDeleted: sevenDaysEarlier
});
const notebook = db.trash.all.find((t) => t.id === notebookId);
if (!notebook || notebook.itemType !== "notebook")
throw new Error("Could not find notebook in trash.");
await db.notebooks.collection.update({
...notebook,
type: "trash",
id: notebookId,
dateDeleted: sevenDaysEarlier
dateDeleted: sevenDaysEarlier,
itemType: "notebook"
});
expect(db.trash.all).toHaveLength(2);
@@ -211,14 +227,17 @@ test("trash cleanup should not delete items newer than 7 days", () =>
test("clear trash should delete note content", () =>
databaseTest().then(async (db) => {
const noteId = await db.notes.add({ ...TEST_NOTE, sessionId: Date.now() });
const noteId = await db.notes.add({
...TEST_NOTE,
sessionId: Date.now().toString()
});
const notebookId = await db.notebooks.add(TEST_NOTEBOOK);
let sessions = await db.noteHistory.get(noteId);
expect(sessions).toHaveLength(1);
let note = { ...db.notes.note(noteId).data };
const note = { ...db.notes.note(noteId)?.data };
await db.notebooks.delete(notebookId);
await db.notes.delete(noteId);
@@ -229,9 +248,9 @@ test("clear trash should delete note content", () =>
expect(db.trash.all).toHaveLength(0);
const content = await db.content.get(note.contentId);
const content = note.contentId && (await db.content.get(note.contentId));
expect(content).toBeUndefined();
sessions = await db.noteHistory.get(note.id);
sessions = await db.noteHistory.get(noteId);
expect(sessions).toHaveLength(0);
}));

View File

@@ -26,23 +26,17 @@ import Compressor from "../../__mocks__/compressor.mock";
import { expect } from "vitest";
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
import { randomBytes } from "../../src/utils/random";
import { GroupOptions, Note, Notebook, Topic } from "../../src/types";
import { GroupOptions, Note, Notebook } from "../../src/types";
import { NoteContent } from "../../src/collections/session-content";
const TEST_NOTEBOOK: Partial<
Omit<Notebook, "topics"> & { topics: Partial<Topic>[] }
> = {
const TEST_NOTEBOOK: Partial<Notebook> = {
title: "Test Notebook",
description: "Test Description",
topics: [{ title: "hello" }]
description: "Test Description"
};
const TEST_NOTEBOOK2: Partial<
Omit<Notebook, "topics"> & { topics: Partial<Topic>[] }
> = {
const TEST_NOTEBOOK2: Partial<Notebook> = {
title: "Test Notebook 2",
description: "Test Description 2",
topics: [{ title: "Home2" }]
description: "Test Description 2"
};
function databaseTest() {

View File

@@ -26,7 +26,6 @@ import {
Notebook,
Reminder,
Tag,
Topic,
TrashItem,
isDeleted
} from "../types";
@@ -55,16 +54,7 @@ export default class Lookup {
}
notebooks(array: Notebook[], query: string) {
return search(
array,
query,
(n) =>
`${n.title} ${n.description} ${n.topics.map((t) => t.title).join(" ")}`
);
}
topics(array: Topic[], query: string) {
return this.byTitle(array, query);
return search(array, query, (n) => `${n.title} ${n.description}}`);
}
tags(array: GroupedItems<Tag>, query: string) {

View File

@@ -335,8 +335,6 @@ class Sync {
}
async stop(lastSynced: number) {
// refresh topic references
this.db.notes.topicReferences.rebuild();
// refresh monographs on sync completed
await this.db.monographs.init();
@@ -373,9 +371,6 @@ class Sync {
* @private
*/
async onPushCompleted(lastSynced: number) {
// refresh topic references
this.db.notes.topicReferences.rebuild();
this.db.eventManager.publish(
EVENTS.databaseSyncRequested,
false,

View File

@@ -85,7 +85,7 @@ class Merger {
ItemMap[TType] | TrashOrItem<Note> | TrashOrItem<Notebook>
>,
type: TType,
lastSynced: number
_lastSynced: number
) {
switch (type) {
case "shortcut":
@@ -93,7 +93,8 @@ class Merger {
case "tag":
case "color":
case "note":
case "relation": {
case "relation":
case "notebook": {
const localItem = this.db[SYNC_COLLECTIONS_MAP[type]].collection.getRaw(
remoteItem.id
);
@@ -102,31 +103,6 @@ class Merger {
}
break;
}
// case "note": {
// const localItem = this.db.notes.collection.getRaw(remoteItem.id);
// if (!localItem || remoteItem.dateModified > localItem.dateModified) {
// return this.db.notes.merge(
// localItem,
// remoteItem as MaybeDeletedItem<TrashOrItem<Note>>
// );
// }
// break;
// }
case "notebook": {
const THRESHOLD = 1000;
const localItem = this.db.notebooks.collection.getRaw(remoteItem.id);
if (
!localItem ||
this.isConflicted(localItem, remoteItem, lastSynced, THRESHOLD)
) {
return this.db.notebooks.merge(
localItem,
remoteItem as MaybeDeletedItem<TrashOrItem<Notebook>>,
lastSynced
);
}
break;
}
}
}

View File

@@ -19,18 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { createNotebookModel } from "../models/notebook";
import { getId } from "../utils/id";
import { CHECK_IDS, checkIsUserPremium } from "../common";
import { CachedCollection } from "../database/cached-collection";
import Topics from "./topics";
import Database from "../api";
import {
MaybeDeletedItem,
Notebook,
Topic,
TrashOrItem,
isDeleted,
isTrashItem
} from "../types";
import { BaseTrashItem, Notebook, TrashOrItem, isTrashItem } from "../types";
import { ICollection } from "./collection";
export class Notebooks implements ICollection {
@@ -51,63 +42,7 @@ export class Notebooks implements ICollection {
return this.collection.init();
}
merge(
localNotebook: MaybeDeletedItem<TrashOrItem<Notebook>> | undefined,
remoteNotebook: MaybeDeletedItem<TrashOrItem<Notebook>>,
lastSyncedTimestamp: number
) {
if (isDeleted(remoteNotebook) || isTrashItem(remoteNotebook))
return remoteNotebook;
if (
localNotebook &&
(isTrashItem(localNotebook) || isDeleted(localNotebook))
) {
if (localNotebook.dateModified > remoteNotebook.dateModified) return;
return remoteNotebook;
}
if (localNotebook && localNotebook.topics?.length) {
let isChanged = false;
// merge new and old topics
for (const oldTopic of localNotebook.topics) {
const newTopicIndex = remoteNotebook.topics.findIndex(
(t) => t.id === oldTopic.id
);
const newTopic = remoteNotebook.topics[newTopicIndex];
// CASE 1: if topic exists in old notebook but not in new notebook, it's deleted.
// However, if the dateEdited of topic in the old notebook is > lastSyncedTimestamp
// it was newly added or edited so add it to the new notebook.
if (!newTopic && oldTopic.dateEdited > lastSyncedTimestamp) {
remoteNotebook.topics.push({ ...oldTopic, dateEdited: Date.now() });
isChanged = true;
}
// CASE 2: if topic exists in new notebook but not in old notebook, it's new.
// This case will be automatically handled as the new notebook is our source of truth.
// CASE 3: if topic exists in both notebooks:
// if oldTopic.dateEdited > newTopic.dateEdited: we keep oldTopic
// and merge the notes of both topics.
else if (newTopic && oldTopic.dateEdited > newTopic.dateEdited) {
remoteNotebook.topics[newTopicIndex] = {
...oldTopic,
dateEdited: Date.now()
};
isChanged = true;
}
}
remoteNotebook.remote = !isChanged;
}
return remoteNotebook;
}
async add(
notebookArg: Partial<
Omit<Notebook, "topics"> & { topics: Partial<Topic>[] }
>
) {
async add(notebookArg: Partial<Notebook>) {
if (!notebookArg) throw new Error("Notebook cannot be undefined or null.");
if (notebookArg.remote)
throw new Error(
@@ -121,17 +56,9 @@ export class Notebooks implements ICollection {
if (oldNotebook && isTrashItem(oldNotebook))
throw new Error("Cannot modify trashed notebooks.");
if (
!oldNotebook &&
this.all.length >= 3 &&
!(await checkIsUserPremium(CHECK_IDS.notebookAdd))
)
return;
const mergedNotebook: Partial<Notebook> = {
...oldNotebook,
...notebookArg,
topics: oldNotebook?.topics || []
...notebookArg
};
if (!mergedNotebook.title)
@@ -143,7 +70,6 @@ export class Notebooks implements ICollection {
title: mergedNotebook.title,
description: mergedNotebook.description,
pinned: !!mergedNotebook.pinned,
topics: mergedNotebook.topics || [],
dateCreated: mergedNotebook.dateCreated || Date.now(),
dateModified: mergedNotebook.dateModified || Date.now(),
@@ -151,10 +77,6 @@ export class Notebooks implements ICollection {
};
await this.collection.add(notebook);
if (!oldNotebook && notebookArg.topics) {
await this.topics(id).add(...notebookArg.topics);
}
return id;
}
@@ -175,7 +97,7 @@ export class Notebooks implements ICollection {
get trashed() {
return this.raw.filter((item) =>
isTrashItem(item)
) as TrashOrItem<Notebook>[];
) as BaseTrashItem<Notebook>[];
}
async pin(...ids: string[]) {
@@ -192,18 +114,17 @@ export class Notebooks implements ICollection {
}
}
topics(id: string) {
return new Topics(id, this.db);
}
totalNotes(id: string) {
const notebook = this.collection.get(id);
if (!notebook || isTrashItem(notebook)) return 0;
let count = 0;
for (const topic of notebook.topics) {
count += this.db.notes.topicReferences.count(topic.id);
const subNotebooks = this.db.relations.from(
{ type: "notebook", id },
"notebook"
);
for (const notebook of subNotebooks) {
count += this.totalNotes(notebook.to.id);
}
return count + this.db.relations.from(notebook, "note").resolved().length;
count += this.db.relations.from({ type: "notebook", id }, "note").length;
return count;
}
notebook(idOrNotebook: string | Notebook) {

View File

@@ -20,20 +20,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { createNoteModel } from "../models/note";
import { getId } from "../utils/id";
import { getContentFromData } from "../content-types";
import { deleteItem, findById } from "../utils/array";
import { NEWLINE_STRIP_REGEX, formatTitle } from "../utils/title-format";
import { clone } from "../utils/clone";
import { Tiptap } from "../content-types/tiptap";
import { EMPTY_CONTENT, isUnencryptedContent } from "./content";
import { CHECK_IDS, checkIsUserPremium } from "../common";
import { buildFromTemplate } from "../utils/templates";
import { Note, TrashOrItem, isTrashItem, isDeleted } from "../types";
import {
Note,
TrashOrItem,
isTrashItem,
isDeleted,
BaseTrashItem
} from "../types";
import Database from "../api";
import { CachedCollection } from "../database/cached-collection";
import { ICollection } from "./collection";
import { NoteContent } from "./session-content";
type NotebookReference = { id: string; topic?: string; rebuildCache?: boolean };
type ExportOptions = {
format: "html" | "md" | "txt" | "md-frontmatter";
contentItem?: NoteContent<false>;
@@ -43,7 +47,6 @@ type ExportOptions = {
export class Notes implements ICollection {
name = "notes";
topicReferences = new NoteIdCache(this);
/**
* @internal
*/
@@ -58,13 +61,11 @@ export class Notes implements ICollection {
async init() {
await this.collection.init();
this.topicReferences.rebuild();
}
async add(
item: Partial<Note & { content: NoteContent<false>; sessionId: string }>
): Promise<string | undefined> {
if (!item) return;
): Promise<string> {
if (item.remote)
throw new Error("Please use db.notes.merge to merge remote notes.");
@@ -80,7 +81,8 @@ export class Notes implements ICollection {
if (oldNote) note.contentId = oldNote.contentId;
if (!oldNote && !item.content && !item.contentId && !item.title) return;
if (!oldNote && !item.content && !item.contentId && !item.title)
throw new Error("Note must have a title or content.");
if (item.content && item.content.data && item.content.type) {
const { type, data } = item.content;
@@ -160,7 +162,9 @@ export class Notes implements ICollection {
}
get trashed() {
return this.raw.filter((item) => isTrashItem(item)) as TrashOrItem<Note>[];
return this.raw.filter((item) =>
isTrashItem(item)
) as BaseTrashItem<Note>[];
}
get pinned() {
@@ -289,142 +293,45 @@ export class Notes implements ICollection {
if (!item) continue;
const itemData = clone(item);
if (itemData.notebooks && !moveToTrash) {
for (const notebook of itemData.notebooks) {
for (const topicId of notebook.topics) {
await this.removeFromNotebook(
{ id: notebook.id, topic: topicId, rebuildCache: false },
id
);
}
}
}
await this.db.relations.unlinkAll(item, "tag");
await this.db.relations.unlinkAll(item, "color");
await this.db.relations.unlinkAll(item, "attachment");
if (moveToTrash && !isTrashItem(itemData))
await this.db.trash.add(itemData);
else {
await this.db.relations.unlinkAll(item, "tag");
await this.db.relations.unlinkAll(item, "color");
await this.db.relations.unlinkAll(item, "attachment");
await this.db.relations.unlinkAll(item, "notebook");
await this.collection.remove(id);
if (itemData.contentId)
await this.db.content.remove(itemData.contentId);
}
}
this.topicReferences.rebuild();
}
async addToNotebook(to: NotebookReference, ...noteIds: string[]) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id) throw new Error("The destination notebook must contain id.");
const { id: notebookId, topic: topicId } = to;
async addToNotebook(notebookId: string, ...noteIds: string[]) {
for (const noteId of noteIds) {
const note = this.collection.get(noteId);
if (!note || isTrashItem(note)) continue;
if (topicId) {
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);
}
} else {
await this.db.relations.add({ id: notebookId, type: "notebook" }, note);
}
await this.db.relations.add(
{ id: notebookId, type: "notebook" },
{ type: "note", id: noteId }
);
}
}
async removeFromNotebook(to: NotebookReference, ...noteIds: string[]) {
if (!to) throw new Error("The destination notebook cannot be undefined.");
if (!to.id) throw new Error("The destination notebook must contain id.");
const { id: notebookId, topic: topicId, rebuildCache = true } = to;
async removeFromNotebook(notebookId: string, ...noteIds: string[]) {
for (const noteId of noteIds) {
const note = this.collection.get(noteId);
if (!note || isTrashItem(note)) {
continue;
}
if (topicId) {
if (!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
});
} else {
await this.db.relations.unlink(
{ id: notebookId, type: "notebook" },
note
);
}
await this.db.relations.unlink(
{ id: notebookId, type: "notebook" },
{ type: "note", id: noteId }
);
}
if (rebuildCache) this.topicReferences.rebuild();
}
async removeFromAllNotebooks(...noteIds: string[]) {
for (const noteId of noteIds) {
const note = this.collection.get(noteId);
if (!note || isTrashItem(note)) {
continue;
}
await this.db.notes.add({
id: noteId,
notebooks: []
});
await this.db.relations.unlinkAll(note, "notebook");
}
this.topicReferences.rebuild();
}
/**
* @internal
*/
async _clearAllNotebookReferences(notebookId: string) {
const notes = this.db.notes.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) continue;
for (const notebook of notebooks) {
if (notebook.id !== notebookId) continue;
if (!deleteItem(notebooks, notebook)) continue;
}
await this.collection.update(note);
await this.db.relations.unlinkAll(
{ type: "note", id: noteId },
"notebook"
);
}
}
@@ -453,48 +360,3 @@ export class Notes implements ICollection {
function getNoteHeadline(content: Tiptap) {
return content.toHeadline();
}
class NoteIdCache {
private cache = new Map<string, string[]>();
constructor(private readonly notes: Notes) {}
rebuild() {
this.cache = new Map();
const notes = this.notes.all;
for (const note of notes) {
const { notebooks } = note;
if (!notebooks) return;
for (const notebook of notebooks) {
for (const topic of notebook.topics) {
this.add(topic, note.id);
}
}
}
}
add(topicId: string, noteId: string) {
let noteIds = this.cache.get(topicId);
if (!noteIds) noteIds = [];
if (noteIds.includes(noteId)) return;
noteIds.push(noteId);
this.cache.set(topicId, noteIds);
}
has(topicId: string, noteId: string) {
const noteIds = this.cache.get(topicId);
if (!noteIds) return false;
return noteIds.includes(noteId);
}
count(topicId: string) {
const noteIds = this.cache.get(topicId);
if (!noteIds) return 0;
return noteIds.length;
}
get(topicId: string) {
return this.cache.get(topicId) || [];
}
}

View File

@@ -25,6 +25,7 @@ import Database from "../api";
type RelationsArray<TType extends keyof ItemMap> = Relation[] & {
resolved: (limit?: number) => ItemMap[TType][];
has: (id: string) => boolean;
};
export class Relations implements ICollection {
@@ -71,9 +72,18 @@ export class Relations implements ICollection {
reference: ItemReference,
type: TType
): RelationsArray<TType> {
const relations = this.all.filter(
(a) => compareItemReference(a.from, reference) && a.to.type === type
);
const relations =
type === "note" || type === "notebook"
? this.all.filter(
(a) =>
compareItemReference(a.from, reference) &&
a.to.type === type &&
!this.db.trash.exists(a.to.id)
)
: this.all.filter(
(a) => compareItemReference(a.from, reference) && a.to.type === type
);
Object.defineProperties(relations, {
resolved: {
writable: false,
@@ -81,6 +91,12 @@ export class Relations implements ICollection {
configurable: false,
value: (limit?: number) =>
this.resolve(limit ? relations.slice(0, limit) : relations, "to")
},
has: {
writable: false,
enumerable: false,
configurable: false,
value: (id: string) => relations.some((rel) => rel.to.id === id)
}
});
return relations as RelationsArray<TType>;
@@ -90,9 +106,17 @@ export class Relations implements ICollection {
reference: ItemReference,
type: TType
): RelationsArray<TType> {
const relations = this.all.filter(
(a) => compareItemReference(a.to, reference) && a.from.type === type
);
const relations =
type === "note" || type === "notebook"
? this.all.filter(
(a) =>
compareItemReference(a.to, reference) &&
a.from.type === type &&
!this.db.trash.exists(a.from.id)
)
: this.all.filter(
(a) => compareItemReference(a.to, reference) && a.from.type === type
);
Object.defineProperties(relations, {
resolved: {
writable: false,
@@ -100,6 +124,12 @@ export class Relations implements ICollection {
configurable: false,
value: (limit?: number) =>
this.resolve(limit ? relations.slice(0, limit) : relations, "from")
},
has: {
writable: false,
enumerable: false,
configurable: false,
value: (id: string) => relations.some((rel) => rel.from.id === id)
}
});
return relations as RelationsArray<TType>;
@@ -133,9 +163,9 @@ export class Relations implements ICollection {
await this.remove(relation.id);
}
async unlinkAll(to: ItemReference, type: keyof ItemMap) {
async unlinkAll(to: ItemReference, type?: keyof ItemMap) {
for (const relation of this.all.filter(
(a) => compareItemReference(a.to, to) && a.from.type === type
(a) => compareItemReference(a.to, to) && (!type || a.from.type === type)
)) {
await this.remove(relation.id);
}

View File

@@ -96,13 +96,6 @@ export class Shortcuts implements ICollection {
item = notebook ? notebook.data : null;
break;
}
case "topic": {
const topic = this.db.notebooks
.topics(shortcut.item.notebookId)
.topic(id);
if (topic) item = topic._topic;
break;
}
case "tag":
item = this.db.tags.tag(id);
break;

View File

@@ -1,114 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { createTopicModel } from "../models/topic";
import { getId } from "../utils/id";
import Database from "../api";
import { clone } from "../utils/clone";
import { Topic } from "../types";
export default class Topics {
constructor(
private readonly notebookId: string,
private readonly db: Database
) {}
has(topic: string) {
return this.all.findIndex((v) => v.id === topic || v.title === topic) > -1;
}
async add(...topics: Partial<Topic>[]) {
const notebook = clone(this.db.notebooks.notebook(this.notebookId)?.data);
if (!notebook) return;
const allTopics = [...notebook.topics, ...topics];
notebook.topics = [];
for (const t of allTopics) {
const topic = makeTopic(t, this.notebookId);
if (!topic) continue;
if (topics.findIndex((t) => t.id === topic.id) > -1)
topic.dateEdited = Date.now();
const index = notebook.topics.findIndex((t) => t.id === topic.id);
if (index > -1) {
notebook.topics[index] = {
...notebook.topics[index],
...topic
};
} else {
notebook.topics.push(topic);
}
}
return this.db.notebooks.collection.update(notebook);
}
get all() {
return this.db.notebooks.notebook(this.notebookId)?.data.topics || [];
}
topic(idOrTitleOrTopic: string | Topic) {
const topic =
typeof idOrTitleOrTopic === "string"
? this.all.find(
(t) => t.id === idOrTitleOrTopic || t.title === idOrTitleOrTopic
)
: idOrTitleOrTopic;
if (!topic) return;
return createTopicModel(topic, this.notebookId, this.db);
}
async delete(...topicIds: string[]) {
const notebook = clone(this.db.notebooks.notebook(this.notebookId)?.data);
if (!notebook) return;
const allTopics = notebook.topics;
for (const topicId of topicIds) {
const topic = this.topic(topicId);
if (!topic) continue;
await topic.clear();
await this.db.shortcuts.remove(topicId);
const topicIndex = allTopics.findIndex(
(t) => t.id === topicId || t.title === topicId
);
allTopics.splice(topicIndex, 1);
}
return this.db.notebooks.collection.update(notebook);
}
}
// we export this for testing.
export function makeTopic(
topic: Partial<Topic>,
notebookId: string
): Topic | undefined {
if (!topic.title) return;
return {
type: "topic",
id: topic.id || getId(),
notebookId: topic.notebookId || notebookId,
title: topic.title.trim(),
dateCreated: topic.dateCreated || Date.now(),
dateEdited: topic.dateEdited || Date.now(),
dateModified: Date.now()
};
}

View File

@@ -19,7 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import dayjs from "dayjs";
import Database from "../api";
import { BaseTrashItem, Note, Notebook, isTrashItem } from "../types";
import {
BaseTrashItem,
Note,
Notebook,
TrashItem,
isTrashItem
} from "../types";
function toTrashItem<T extends Note | Notebook>(item: T): BaseTrashItem<T> {
return {
@@ -33,10 +39,12 @@ function toTrashItem<T extends Note | Notebook>(item: T): BaseTrashItem<T> {
export default class Trash {
collections = ["notes", "notebooks"] as const;
cache: string[] = [];
constructor(private readonly db: Database) {}
async init() {
await this.cleanup();
this.cache = this.all.map((t) => t.id);
}
async cleanup() {
@@ -54,8 +62,8 @@ export default class Trash {
}
}
get all() {
const trashItems = [];
get all(): TrashItem[] {
const trashItems: TrashItem[] = [];
for (const key of this.collections) {
const collection = this.db[key];
trashItems.push(...collection.trashed);
@@ -78,22 +86,22 @@ export default class Trash {
} else if (item.type === "notebook") {
await this.db.notebooks.collection.update(toTrashItem(item));
}
this.cache.push(item.id);
}
async delete(...ids: string[]) {
for (const id of ids) {
if (!id) continue;
const [item, collection] = this.getItem(id);
if (!item || !collection) continue;
if (item.itemType === "note") {
if (item.contentId) await this.db.content.remove(item.contentId);
await this.db.noteHistory.clearSessions(id);
} else if (item.itemType === "notebook") {
await this.db.notes._clearAllNotebookReferences(item.id);
await this.db.relations.unlinkAll({ type: "notebook", id: item.id });
}
await collection.remove(id);
this.cache.splice(this.cache.indexOf(id), 1);
}
this.db.notes.topicReferences.rebuild();
}
async restore(...ids: string[]) {
@@ -108,14 +116,15 @@ export default class Trash {
type: "notebook"
});
}
this.cache.splice(this.cache.indexOf(id), 1);
}
this.db.notes.topicReferences.rebuild();
}
async clear() {
for (const item of this.all) {
await this.delete(item.id);
}
this.cache = [];
}
synced(id: string) {
@@ -131,6 +140,6 @@ export default class Trash {
* @param {string} id
*/
exists(id: string) {
return this.all.findIndex((item) => item.id === id) > -1;
return this.cache.includes(id);
}
}

View File

@@ -246,6 +246,15 @@ const migrations: Migration[] = [
await db.colors.delete(oldColorId);
}
}
if (item.notebooks) {
for (const notebook of item.notebooks) {
for (const topic of notebook.topics) {
await db.relations.add({ type: "notebook", id: topic }, item);
}
}
}
delete item.tags;
delete item.color;
return true;
@@ -259,6 +268,27 @@ const migrations: Migration[] = [
}
delete item.noteIds;
return true;
},
notebook: async (item, db) => {
for (const topic of item.topics || []) {
const subNotebookId = await db.notebooks.add({
id: topic.id,
title: topic.title,
dateCreated: topic.dateCreated,
dateEdited: topic.dateEdited,
dateModified: topic.dateModified
});
if (!subNotebookId) continue;
await db.relations.add(item, { id: subNotebookId, type: "notebook" });
}
delete item.topics;
return true;
},
shortcut: (item) => {
if (item.item.type === "topic") {
item.item = { type: "notebook", id: item.item.id };
return true;
}
}
}
},

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Database from "../api";
import Topics from "../collections/topics";
import { Notebook } from "../types";
export function createNotebookModel(notebook: Notebook, db: Database) {
@@ -34,10 +33,6 @@ export function createNotebookModel(notebook: Notebook, db: Database) {
totalNotes: (function () {
return db.notebooks.totalNotes(notebook.id);
})(),
/**
* @deprecated please use `db.notebooks.topics()` instead
*/
topics: new Topics(notebook.id, db),
/**
* @deprecated please use `db.notebooks.pin()` & `db.notebooks.unpin()` instead.
*/

View File

@@ -1,76 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Database from "../api";
import { Note, Topic } from "../types";
import { clone } from "../utils/clone";
type TopicModel = Topic & {
_topic: Topic;
totalNotes: number;
has: (noteId: string) => boolean;
all: Note[];
clear: () => Promise<void>;
};
export function createTopicModel(
topic: Topic,
notebookId: string,
db: Database
): TopicModel {
return Object.defineProperties(clone(topic), {
_topic: {
get: () => topic
},
totalNotes: {
get: () => db.notes?.topicReferences.count(topic.id)
},
has: {
value: (noteId: string) => {
return db.notes.topicReferences.has(topic.id, noteId);
}
},
all: {
get: () => getAllNotes(db, topic.id)
},
clear: {
value: () => {
const noteIds = db.notes?.topicReferences.get(topic.id);
if (!noteIds.length) return;
return db.notes.removeFromNotebook(
{
topic: topic.id,
id: notebookId,
rebuildCache: true
},
...noteIds
);
}
}
}) as TopicModel;
}
function getAllNotes(db: Database, topicId: string) {
const noteIds = db.notes.topicReferences.get(topicId);
return noteIds.reduce<Note[]>((arr, noteId) => {
const note = db.notes.note(noteId);
if (note) arr.push(note.data);
return arr;
}, []);
}

View File

@@ -139,6 +139,9 @@ export interface Note extends BaseItem<"note"> {
* @deprecated only kept here for migration purposes.
*/
color?: string;
/**
* @deprecated only kept here for migration purposes.
*/
notebooks?: NotebookReference[];
pinned: boolean;
@@ -156,9 +159,15 @@ export interface Notebook extends BaseItem<"notebook"> {
description?: string;
dateEdited: number;
pinned: boolean;
topics: Topic[];
/**
* @deprecated only kept here for migration purposes.
*/
topics?: Topic[];
}
/**
* @deprecated only kept here for migration purposes.
*/
export interface Topic extends BaseItem<"topic"> {
title: string;
notebookId: string;
@@ -235,6 +244,9 @@ type TagNotebookShortcutReference = BaseShortcutReference & {
type: "tag" | "notebook";
};
/**
* @deprecated only kept here for migration purposes
*/
type TopicShortcutReference = BaseShortcutReference & {
type: "topic";
notebookId: string;