diff --git a/addon/chrome/content/preferences.xhtml b/addon/chrome/content/preferences.xhtml
index abb0f1e..95eabdb 100644
--- a/addon/chrome/content/preferences.xhtml
+++ b/addon/chrome/content/preferences.xhtml
@@ -12,11 +12,6 @@
native="true"
preference="__prefsPrefix__.openNote.takeover"
/>
-
diff --git a/addon/prefs.js b/addon/prefs.js
index 172c938..0db33c5 100644
--- a/addon/prefs.js
+++ b/addon/prefs.js
@@ -29,4 +29,3 @@ pref("__prefsPrefix__.workspace.outline.expandLevel", 2);
pref("__prefsPrefix__.workspace.outline.keepLinks", true);
pref("__prefsPrefix__.openNote.takeover", true);
-pref("__prefsPrefix__.related.takeover", false);
diff --git a/package.json b/package.json
index d20f9cb..13af72a 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"homepage": "https://github.com/windingwind/zotero-better-notes#readme",
"dependencies": {
"asciidoctor": "^3.0.2",
+ "dexie": "^4.0.4",
"diff": "^5.1.0",
"hast-util-to-html": "^9.0.0",
"hast-util-to-mdast": "^8.4.1",
diff --git a/src/addon.ts b/src/addon.ts
index 9899a01..d2bc6ff 100644
--- a/src/addon.ts
+++ b/src/addon.ts
@@ -73,6 +73,9 @@ class Addon {
data: Record;
};
};
+ relation: {
+ worker?: Worker;
+ };
readonly prompt?: Prompt;
} = {
alive: true,
@@ -115,6 +118,7 @@ class Addon {
data: {},
},
},
+ relation: {},
get prompt() {
return ToolkitGlobal.getInstance().prompt.instance;
},
diff --git a/src/api.ts b/src/api.ts
index 0271fc5..1670042 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -65,7 +65,11 @@ import {
getNoteTreeFlattened,
getLinesInNote,
} from "./utils/note";
-import { updateRelatedNotes, getRelatedNoteIds } from "./utils/related";
+import {
+ getNoteLinkInboundRelation,
+ getNoteLinkOutboundRelation,
+ updateNoteLinkRelation,
+} from "./utils/relation";
const workspace = {};
@@ -145,8 +149,9 @@ const note = {
};
const related = {
- updateRelatedNotes,
- getRelatedNoteIds,
+ getNoteLinkInboundRelation,
+ getNoteLinkOutboundRelation,
+ updateNoteLinkRelation,
};
export default {
diff --git a/src/elements/outlinePane.ts b/src/elements/outlinePane.ts
index d12cc1c..8879abb 100644
--- a/src/elements/outlinePane.ts
+++ b/src/elements/outlinePane.ts
@@ -169,9 +169,7 @@ export class OutlinePane extends PluginCEBase {
if (event === "modify" && type === "item") {
if ((ids as number[]).includes(this.item.id)) {
this.updateOutline();
- if (getPref("related.takeover")) {
- this._addon.api.related.updateRelatedNotes(this.item.id);
- }
+ this._addon.api.related.updateNoteLinkRelation(this.item.id);
}
}
}
diff --git a/src/elements/related.ts b/src/elements/related.ts
index a7a28f3..a0ca49c 100644
--- a/src/elements/related.ts
+++ b/src/elements/related.ts
@@ -78,10 +78,7 @@ export class NoteRelatedBox extends RelatedBox {
row.append(note);
}
- if (
- this.editable &&
- (!relatedItem.isNote() || !getPref("related.takeover"))
- ) {
+ if (this.editable) {
const remove = document.createXULElement("toolbarbutton");
remove.addEventListener("command", () => this._handleRemove(id));
remove.className = "zotero-clicky zotero-clicky-minus";
diff --git a/src/elements/workspace.ts b/src/elements/workspace.ts
index 0add51e..9126e17 100644
--- a/src/elements/workspace.ts
+++ b/src/elements/workspace.ts
@@ -58,6 +58,8 @@ export class Workspace extends PluginCEBase {
}
set item(val) {
+ if (!val) return;
+ this._addon.api.related.updateNoteLinkRelation(val.id);
this._item = val;
this._outline.item = val;
this._context.item = val;
@@ -83,6 +85,7 @@ export class Workspace extends PluginCEBase {
this._loadPersist();
this.resizeOb = new ResizeObserver(() => {
+ if (!this.editor) return;
this._addon.api.editor.scroll(
this.editor,
this._addon.api.editor.getLineAtCursor(this.editor),
diff --git a/src/extras/relationWorker.ts b/src/extras/relationWorker.ts
new file mode 100644
index 0000000..17fc85b
--- /dev/null
+++ b/src/extras/relationWorker.ts
@@ -0,0 +1,108 @@
+import Dexie from "dexie";
+
+const db = new Dexie("BN_Two_Way_Relation") as Dexie & {
+ link: Dexie.Table;
+};
+
+db.version(1).stores({
+ link: "++id, fromLibID, fromKey, toLibID, toKey, fromLine, toLine, toSection, url",
+});
+
+console.log("Using Dexie v" + Dexie.semVer, db);
+
+postMessage({
+ type: "ready",
+});
+
+async function addLink(model: LinkModel) {
+ await db.link.add(model);
+}
+
+async function bulkAddLink(models: LinkModel[]) {
+ await db.link.bulkAdd(models);
+}
+
+async function rebuildLinkForNote(
+ fromLibID: number,
+ fromKey: string,
+ links: LinkModel[],
+) {
+ console.log("rebuildLinkForNote", fromLibID, fromKey, links);
+ await db.link
+ .where({ fromLibID, fromKey })
+ .delete()
+ .then((deleteCount) => {
+ console.log("Deleted " + deleteCount + " objects");
+ bulkAddLink(links);
+ });
+}
+
+async function getOutboundLinks(fromLibID: number, fromKey: string) {
+ console.log("getOutboundLinks", fromLibID, fromKey);
+ return db.link.where({ fromLibID, fromKey }).toArray();
+}
+
+async function getInboundLinks(toLibID: number, toKey: string) {
+ console.log("getInboundLinks", toLibID, toKey);
+ return db.link.where({ toLibID, toKey }).toArray();
+}
+
+interface LinkModel {
+ fromLibID: number;
+ fromKey: string;
+ toLibID: number;
+ toKey: string;
+ fromLine: number;
+ toLine: number | null;
+ toSection: string | null;
+ url: string;
+}
+
+// Handle messages from the main thread and send responses back for await
+onmessage = async (event) => {
+ const { type, jobID, data } = event.data;
+ console.log("Worker received message", type, jobID, data);
+ switch (type) {
+ case "addLink":
+ postMessage({
+ type,
+ jobID,
+ result: await addLink(data),
+ });
+ break;
+ case "bulkAddLink":
+ postMessage({
+ type,
+ jobID,
+ result: await bulkAddLink(data),
+ });
+ break;
+ case "rebuildLinkForNote":
+ postMessage({
+ type,
+ jobID,
+ result: await rebuildLinkForNote(
+ data.fromLibID,
+ data.fromKey,
+ data.links,
+ ),
+ });
+ break;
+ case "getOutboundLinks":
+ postMessage({
+ type,
+ jobID,
+ result: await getOutboundLinks(data.fromLibID, data.fromKey),
+ });
+ break;
+ case "getInboundLinks":
+ postMessage({
+ type,
+ jobID,
+ result: await getInboundLinks(data.toLibID, data.toKey),
+ });
+ break;
+ default:
+ break;
+ }
+};
diff --git a/src/hooks.ts b/src/hooks.ts
index a11b9d9..d87227d 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -35,10 +35,7 @@ import { createZToolkit } from "./utils/ztoolkit";
import { waitUtilAsync } from "./utils/wait";
import { initSyncList } from "./modules/sync/api";
import { patchViewItems } from "./modules/viewItems";
-import {
- onUpdateRelated,
- promptRelatedPermission,
-} from "./modules/relatedNotes";
+import { onUpdateRelated } from "./modules/relatedNotes";
import { getFocusedWindow } from "./utils/window";
import { registerNoteRelation } from "./modules/workspace/relation";
import { getPref } from "./utils/prefs";
@@ -69,8 +66,6 @@ async function onStartup() {
setSyncing();
- promptRelatedPermission();
-
await onMainWindowLoad(window);
}
@@ -130,7 +125,7 @@ function onNotify(
skipActive: true,
reason: "item-modify",
});
- addon.hooks.onUpdateRelated(modifiedNotes, { skipActive: true });
+ addon.hooks.onUpdateRelated(modifiedNotes);
onUpdateNoteTabsTitle(modifiedNotes);
}
} else {
diff --git a/src/modules/editor/toolbar.ts b/src/modules/editor/toolbar.ts
index d0ea7fa..db42abc 100644
--- a/src/modules/editor/toolbar.ts
+++ b/src/modules/editor/toolbar.ts
@@ -174,7 +174,7 @@ async function getMenuData(editor: Zotero.EditorInstance) {
id: makeId("settings-updateRelatedNotes"),
text: getString("editor-toolbar-settings-updateRelatedNotes"),
callback: (e) => {
- addon.api.related.updateRelatedNotes(e.editor._item.id);
+ addon.api.related.updateNoteLinkRelation(e.editor._item.id);
},
},
]),
diff --git a/src/modules/relatedNotes.ts b/src/modules/relatedNotes.ts
index 29daa2f..f1b18fc 100644
--- a/src/modules/relatedNotes.ts
+++ b/src/modules/relatedNotes.ts
@@ -1,49 +1,7 @@
-import { getPref, setPref } from "../utils/prefs";
+export { onUpdateRelated };
-export { onUpdateRelated, promptRelatedPermission };
-
-function onUpdateRelated(
- items: Zotero.Item[] = [],
- { skipActive } = {
- skipActive: true,
- },
-) {
- if (!getPref("related.takeover")) {
- return;
- }
- if (skipActive) {
- // Skip active note editors' targets
- const activeNoteIds = Zotero.Notes._editorInstances
- .filter(
- (editor) =>
- !Components.utils.isDeadWrapper(editor._iframeWindow) &&
- editor._iframeWindow.document.hasFocus(),
- )
- .map((editor) => editor._item.id);
- const filteredItems = items.filter(
- (item) => !activeNoteIds.includes(item.id),
- );
- items = filteredItems;
- }
+function onUpdateRelated(items: Zotero.Item[] = []) {
for (const item of items) {
- addon.api.related.updateRelatedNotes(item.id);
- }
-}
-
-function promptRelatedPermission() {
- if (getPref("related.takeover")) {
- return;
- }
- const result = Zotero.Prompt.confirm({
- title: "Permission Request",
- text: `Better Notes want to take over (add and remove) related field of your notes.
-If you refuse, you can still use Better Notes, but most of the linking features will not work.
-You can change this permission in settings later.`,
- button0: "Allow",
- button1: "Refuse",
- });
-
- if (result === 0) {
- setPref("related.takeover", true);
+ addon.api.related.updateNoteLinkRelation(item.id);
}
}
diff --git a/src/modules/sync/infoWindow.ts b/src/modules/sync/infoWindow.ts
index a764c40..9d3d656 100644
--- a/src/modules/sync/infoWindow.ts
+++ b/src/modules/sync/infoWindow.ts
@@ -44,12 +44,17 @@ export async function showSyncInfo(noteId: number) {
})
.addButton(getString("syncInfo.unSync"), "unSync", {
callback: async (ev) => {
- const { detectedIDSet } =
- await addon.api.related.getRelatedNoteIds(noteId);
- for (const itemId of Array.from(detectedIDSet)) {
- addon.api.sync.removeSyncNote(itemId);
+ const outLink =
+ await addon.api.related.getNoteLinkOutboundRelation(noteId);
+ for (const linkData of outLink) {
+ const noteItem = await Zotero.Items.getByLibraryAndKeyAsync(
+ linkData.toLibID,
+ linkData.toKey,
+ );
+ if (!noteItem) continue;
+ addon.api.sync.removeSyncNote(noteItem.id);
}
- showHint(`Cancel sync of ${detectedIDSet.size} notes.`);
+ showHint(`Cancel sync of ${outLink.length} notes.`);
},
})
.addButton(getString("syncInfo.reveal"), "reveal", {
diff --git a/src/modules/workspace/relation.ts b/src/modules/workspace/relation.ts
index 4020764..3e3244e 100644
--- a/src/modules/workspace/relation.ts
+++ b/src/modules/workspace/relation.ts
@@ -78,48 +78,53 @@ async function refresh(body: HTMLElement, item: Zotero.Item) {
async function getRelationData(note: Zotero.Item) {
if (!note) return;
- const currentContent = note.getNote();
- const currentLink = addon.api.convert.note2link(note);
- const currentTitle = slice(note.getNoteTitle(), 15);
- const { detectedIDSet, currentIDSet } =
- await addon.api.related.getRelatedNoteIds(note.id);
- if (!areSetsEqual(detectedIDSet, currentIDSet)) {
- await addon.api.related.updateRelatedNotes(note.id);
- }
- const items = Zotero.Items.get(Array.from(detectedIDSet));
+ const inLink = await addon.api.related.getNoteLinkInboundRelation(note.id);
+ const outLink = await addon.api.related.getNoteLinkOutboundRelation(note.id);
- const nodes = [];
const links = [];
- for (const item of items) {
- const compareContent = item.getNote();
- const compareLink = addon.api.convert.note2link(item);
- const compareTitle = slice(item.getNoteTitle(), 15);
+ const noteSet: Set = new Set();
- if (currentLink && compareContent.includes(currentLink)) {
- links.push({
- source: item.id,
- target: note.id,
- value: 1,
- });
- }
- if (compareLink && currentContent.includes(compareLink)) {
- links.push({
- source: note.id,
- target: item.id,
- value: 1,
- });
- }
-
- nodes.push({
- id: item.id,
- title: compareTitle,
- group: 2,
+ for (const linkData of inLink) {
+ const noteItem = await Zotero.Items.getByLibraryAndKeyAsync(
+ linkData.fromLibID,
+ linkData.fromKey,
+ );
+ if (!noteItem) continue;
+ noteSet.add(noteItem.id);
+ links.push({
+ source: noteItem.id,
+ target: note.id,
+ value: 1,
});
}
+ for (const linkData of outLink) {
+ const noteItem = await Zotero.Items.getByLibraryAndKeyAsync(
+ linkData.toLibID,
+ linkData.toKey,
+ );
+ if (!noteItem) continue;
+ noteSet.add(noteItem.id);
+ links.push({
+ source: note.id,
+ target: noteItem.id,
+ value: 1,
+ });
+ }
+
+ noteSet.delete(note.id);
+ const nodes = Array.from(noteSet).map((id) => {
+ const item = Zotero.Items.get(id);
+ return {
+ id: item.id,
+ title: slice(item.getNoteTitle(), 15),
+ group: 2,
+ };
+ });
+
nodes.push({
id: note.id,
- title: currentTitle,
+ title: slice(note.getNoteTitle(), 15),
group: 1,
});
diff --git a/src/utils/related.ts b/src/utils/related.ts
deleted file mode 100644
index 93c7e67..0000000
--- a/src/utils/related.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { getNoteLink, getNoteLinkParams } from "./link";
-
-export { getRelatedNoteIds, updateRelatedNotes };
-
-async function updateRelatedNotes(noteID: number) {
- const noteItem = Zotero.Items.get(noteID);
- if (!noteItem) {
- ztoolkit.log(`updateRelatedNotes: ${noteID} is not a note.`);
- return;
- }
- const { detectedIDSet, currentIDSet } = await getRelatedNoteIds(noteID);
-
- await Zotero.DB.executeTransaction(async () => {
- const saveParams = {
- skipDateModifiedUpdate: true,
- skipSelect: true,
- notifierData: {
- skipBN: true,
- },
- };
- for (const toAddNote of Zotero.Items.get(Array.from(detectedIDSet))) {
- if (currentIDSet.has(toAddNote.id)) {
- // Remove existing notes from current dict for later process
- currentIDSet.delete(toAddNote.id);
- continue;
- }
- toAddNote.addRelatedItem(noteItem);
- noteItem.addRelatedItem(toAddNote);
- toAddNote.save(saveParams);
- currentIDSet.delete(toAddNote.id);
- }
- for (const toRemoveNote of Zotero.Items.get(Array.from(currentIDSet))) {
- // Remove related notes that are not in the new list
- toRemoveNote.removeRelatedItem(noteItem);
- noteItem.removeRelatedItem(toRemoveNote);
- toRemoveNote.save(saveParams);
- }
- noteItem.save(saveParams);
- });
-}
-
-async function getRelatedNoteIds(noteId: number) {
- let detectedIDs: number[] = [];
- const note = Zotero.Items.get(noteId);
- const linkMatches = note.getNote().match(/zotero:\/\/note\/\w+\/\w+\//g);
- const currentIDs: number[] = [];
-
- if (linkMatches) {
- const subNoteIds = (
- await Promise.all(
- linkMatches.map(async (link) => getNoteLinkParams(link).noteItem),
- )
- )
- .filter((item) => item && item.isNote())
- .map((item) => (item as Zotero.Item).id);
- detectedIDs = detectedIDs.concat(subNoteIds);
- }
-
- const currentNoteLink = getNoteLink(note);
- if (currentNoteLink) {
- // Get current related items
- for (const relItemKey of note.relatedItems) {
- try {
- const relItem = (await Zotero.Items.getByLibraryAndKeyAsync(
- note.libraryID,
- relItemKey,
- )) as Zotero.Item;
-
- // If the related item is a note and contains the current note link
- // Add it to the related note list
- if (relItem.isNote()) {
- if (relItem.getNote().includes(currentNoteLink)) {
- detectedIDs.push(relItem.id);
- }
- currentIDs.push(relItem.id);
- }
- } catch (e) {
- ztoolkit.log(e);
- }
- }
- }
-
- const detectedIDSet = new Set(detectedIDs);
- detectedIDSet.delete(noteId);
- const currentIDSet = new Set(currentIDs);
- return { detectedIDSet, currentIDSet };
-}
diff --git a/src/utils/relation.ts b/src/utils/relation.ts
new file mode 100644
index 0000000..d9f2c8a
--- /dev/null
+++ b/src/utils/relation.ts
@@ -0,0 +1,128 @@
+import { config } from "../../package.json";
+import { getNoteLinkParams } from "./link";
+
+export {
+ updateNoteLinkRelation,
+ getNoteLinkInboundRelation,
+ getNoteLinkOutboundRelation,
+};
+
+async function getRelationWorker() {
+ if (addon.data.relation.worker) {
+ return addon.data.relation.worker;
+ }
+ const deferred = Zotero.Promise.defer();
+ const worker = new Worker(
+ `chrome://${config.addonRef}/content/scripts/relationWorker.js`,
+ );
+ addon.data.relation.worker = worker;
+ worker.addEventListener(
+ "message",
+ (e) => {
+ if (e.data === "ready") {
+ ztoolkit.log("Relation worker is ready.");
+ deferred.resolve();
+ }
+ },
+ { once: true },
+ );
+ await deferred.promise;
+ return worker;
+}
+
+async function executeRelationWorker(data: {
+ type: string;
+ data: any;
+}): Promise {
+ const worker = await getRelationWorker();
+ const deferred = Zotero.Promise.defer();
+ const jobID = Zotero.Utilities.randomString(8);
+ let retData;
+ ztoolkit.log("executeRelationWorker", data, jobID);
+ worker.addEventListener(
+ "message",
+ (e) => {
+ if (e.data.jobID === jobID) {
+ retData = e.data;
+ deferred.resolve();
+ }
+ },
+ { once: true },
+ );
+ worker.postMessage({ ...data, jobID });
+ await Promise.race([deferred.promise, Zotero.Promise.delay(5000)]);
+ if (!retData) {
+ throw new Error(`Worker timeout: ${data.type}, ${jobID}`);
+ }
+ ztoolkit.log("executeRelationWorker return", retData);
+ return (retData as { result: any }).result;
+}
+
+async function updateNoteLinkRelation(noteID: number) {
+ const note = Zotero.Items.get(noteID);
+ const fromLibID = note.libraryID;
+ const fromKey = note.key;
+ const lines = addon.api.note.getLinesInNote(note);
+ const linkToData: LinkModel[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ const linkMatches = lines[i].match(/zotero:\/\/note\/\w+\/\w+\//g);
+ if (!linkMatches) {
+ continue;
+ }
+ for (const link of linkMatches) {
+ const { noteItem, libraryID, noteKey, lineIndex, sectionName } =
+ getNoteLinkParams(link);
+ if (noteItem && noteItem.isNote()) {
+ linkToData.push({
+ fromLibID,
+ fromKey,
+ toLibID: libraryID,
+ toKey: noteKey!,
+ fromLine: i,
+ toLine: lineIndex ?? null,
+ toSection: sectionName ?? null,
+ url: link,
+ });
+ }
+ }
+ }
+ await executeRelationWorker({
+ type: "rebuildLinkForNote",
+ data: { fromLibID, fromKey, links: linkToData },
+ });
+}
+
+async function getNoteLinkOutboundRelation(
+ noteID: number,
+): Promise {
+ const note = Zotero.Items.get(noteID);
+ const fromLibID = note.libraryID;
+ const fromKey = note.key;
+ return executeRelationWorker({
+ type: "getOutboundLinks",
+ data: { fromLibID, fromKey },
+ });
+}
+
+async function getNoteLinkInboundRelation(
+ noteID: number,
+): Promise {
+ const note = Zotero.Items.get(noteID);
+ const toLibID = note.libraryID;
+ const toKey = note.key;
+ return executeRelationWorker({
+ type: "getInboundLinks",
+ data: { toLibID, toKey },
+ });
+}
+
+interface LinkModel {
+ fromLibID: number;
+ fromKey: string;
+ toLibID: number;
+ toKey: string;
+ fromLine: number;
+ toLine: number | null;
+ toSection: string | null;
+ url: string;
+}