diff --git a/.zed/tasks.json b/.zed/tasks.json
index aee119fe..f3a666d7 100644
--- a/.zed/tasks.json
+++ b/.zed/tasks.json
@@ -2,9 +2,9 @@
[
{
"label": "Build",
- "command": "npm",
+ "command": "npm run build",
// rest of the parameters are optional
- "args": ["run", "build"],
+ // "args": [],
// Env overrides for the command, will be appended to the terminal's environment from the settings.
// "env": { "foo": "bar" },
// Current working directory to spawn the command into, defaults to current project root.
@@ -20,38 +20,45 @@
},
{
"label": "Build with source maps",
- "command": "npm",
- "args": ["run", "build", "--", "--sourcemap"],
+ "command": "npm run build -- --sourcemap",
"reveal": "never"
},
{
"label": "Generate Fluent types",
- "command": "npm",
- "args": ["run", "generate-fluent-types"],
+ "command": "npm run generate-fluent-types",
"reveal": "never"
},
{
"label": "Start Zotero",
- "command": "npm",
- "args": ["run", "start"],
+ "command": "npm run start",
"reveal": "never"
},
{
"label": "Start Zotero Beta",
- "command": "npm",
- "args": ["run", "start:beta"],
+ "command": "npm run start:beta",
"reveal": "never"
},
{
- "label": "Test",
- "command": "npm",
- "args": ["run", "test"],
+ "label": "Test all (once)",
+ "command": "npm run test",
"reveal": "always"
},
{
- "label": "Test watch",
- "command": "npm",
- "args": ["run", "test:watch"],
+ "label": "Test all (watch)",
+ "command": "npm run test:watch",
"reveal": "always"
+ },
+ {
+ "label": "Test $ZED_STEM",
+ "command": "npx vitest related",
+ "args": ["\"$ZED_RELATIVE_FILE\""],
+ "reveal": "always"
+ },
+ {
+ "label": "Test $ZED_SYMBOL",
+ "command": "npx vitest",
+ "args": ["\"$ZED_RELATIVE_FILE\"", "--testNamePattern", "\"$ZED_SYMBOL\""],
+ "reveal": "always",
+ "tags": ["ts-test", "tsx-test"]
}
]
diff --git a/src/content/data/__tests__/item-data.spec.ts b/src/content/data/__tests__/item-data.spec.ts
index 49b6b1fa..9f1d6867 100644
--- a/src/content/data/__tests__/item-data.spec.ts
+++ b/src/content/data/__tests__/item-data.spec.ts
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { createZoteroItemMock } from '../../../../test/utils';
-import { getSyncedNotesFromAttachment } from '../item-data';
+import { createZoteroItemMock, mockZoteroPrefs } from '../../../../test/utils';
+import { NoteroPref, setNoteroPref } from '../../prefs/notero-pref';
+import {
+ getSyncedNotesFromAttachment,
+ saveNotionLinkAttachment,
+} from '../item-data';
describe('getSyncedNotesFromAttachment', () => {
it('loads expected data when synced notes are saved in original format', () => {
@@ -50,3 +54,49 @@ describe('getSyncedNotesFromAttachment', () => {
});
});
});
+
+describe('saveNotionLinkAttachment', () => {
+ it('preserves synced notes when page ID does not change', async () => {
+ mockZoteroPrefs();
+ setNoteroPref(NoteroPref.syncNotes, true);
+ const pageURL =
+ 'notion://www.notion.so/page-00000000000000000000000000000000';
+ const syncedNotes =
+ '
{"existing":"notes"}
';
+ const item = createZoteroItemMock();
+ const attachment = createZoteroItemMock();
+ item.getAttachments.mockReturnValue([attachment.id]);
+ attachment.getField.calledWith('url').mockReturnValue(pageURL);
+ attachment.getNote.mockReturnValue(syncedNotes);
+
+ await saveNotionLinkAttachment(item, pageURL);
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(attachment.setNote).toHaveBeenCalledWith(
+ expect.stringContaining(syncedNotes),
+ );
+ });
+
+ it('resets synced notes when page ID changes', async () => {
+ mockZoteroPrefs();
+ setNoteroPref(NoteroPref.syncNotes, true);
+ const oldPageURL =
+ 'notion://www.notion.so/old-page-00000000000000000000000000000000';
+ const newPageURL =
+ 'notion://www.notion.so/new-page-77777777777777777777777777777777';
+ const syncedNotes =
+ '{"existing":"notes"}
';
+ const item = createZoteroItemMock();
+ const attachment = createZoteroItemMock();
+ item.getAttachments.mockReturnValue([attachment.id]);
+ attachment.getField.calledWith('url').mockReturnValue(oldPageURL);
+ attachment.getNote.mockReturnValue(syncedNotes);
+
+ await saveNotionLinkAttachment(item, newPageURL);
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(attachment.setNote).toHaveBeenCalledWith(
+ expect.stringContaining('{}
'),
+ );
+ });
+});
diff --git a/src/content/data/item-data.ts b/src/content/data/item-data.ts
index 3c411ca5..ff349df5 100644
--- a/src/content/data/item-data.ts
+++ b/src/content/data/item-data.ts
@@ -49,9 +49,13 @@ export async function saveNotionLinkAttachment(
await Zotero.Items.erase(attachmentIDs);
}
- let attachment = attachments.length ? attachments[0] : null;
+ let attachment = attachments[0];
+ let pageIDChanged = false;
if (attachment) {
+ const currentURL = attachment.getField('url');
+ pageIDChanged =
+ !currentURL || getPageIDFromURL(currentURL) !== getPageIDFromURL(appURL);
attachment.setField('url', appURL);
} else {
attachment = await Zotero.Attachments.linkFromURL({
@@ -64,7 +68,8 @@ export async function saveNotionLinkAttachment(
});
}
- updateNotionLinkAttachmentNote(attachment);
+ const syncedNotes = pageIDChanged ? {} : undefined;
+ updateNotionLinkAttachmentNote(attachment, syncedNotes);
await attachment.saveTx();
}
@@ -157,7 +162,7 @@ export async function saveSyncedNote(
function updateNotionLinkAttachmentNote(
attachment: Zotero.Item,
- syncedNotes?: Required,
+ syncedNotes?: SyncedNotes,
) {
let note = `
Do not modify or delete!
diff --git a/src/content/errors/ItemSyncError.ts b/src/content/errors/ItemSyncError.ts
new file mode 100644
index 00000000..01e77b6a
--- /dev/null
+++ b/src/content/errors/ItemSyncError.ts
@@ -0,0 +1,11 @@
+export class ItemSyncError extends Error {
+ public readonly item: Zotero.Item;
+ public readonly name = 'ItemSyncError';
+
+ public constructor(cause: unknown, item: Zotero.Item) {
+ super(`Failed to sync item with ID ${item.id} due to ${String(cause)}`, {
+ cause,
+ });
+ this.item = item;
+ }
+}
diff --git a/src/content/errors/index.ts b/src/content/errors/index.ts
index f57b9ba8..f1bf879a 100644
--- a/src/content/errors/index.ts
+++ b/src/content/errors/index.ts
@@ -1,2 +1,3 @@
+export { ItemSyncError } from './ItemSyncError';
export { LocalizableError } from './LocalizableError';
export { MissingPrefError } from './MissingPrefError';
diff --git a/src/content/prefs/preferences.tsx b/src/content/prefs/preferences.tsx
index 8ad6f72d..3ea4c74c 100644
--- a/src/content/prefs/preferences.tsx
+++ b/src/content/prefs/preferences.tsx
@@ -7,6 +7,7 @@ import type { createRoot } from 'react-dom/client';
import { FluentMessageId } from '../../locale/fluent-types';
import { LocalizableError } from '../errors';
import { getNotionClient } from '../sync/notion-client';
+import { normalizeID } from '../sync/notion-utils';
import {
createXULElement,
getLocalizedErrorMessage,
@@ -157,14 +158,13 @@ class Preferences {
const databases = await this.retrieveNotionDatabases();
menuItems = databases.map