Skip to content

Commit

Permalink
feat: Add utility for finding duplicate Notion pages (#433)
Browse files Browse the repository at this point in the history
* refactor: Move element utility functions into a single file

* refactor: Correctly indicate that `getXULElementById` can return null

* refactor: Extract `WindowManager` service for providing latest window

* refactor: Add index file for test utils

* refactor: Add `getRequiredNoteroPref` that throws if missing value

* refactor: Rename Notion types for clarity

* feat: Add utility for finding duplicate Notion pages

* feat: Support finding duplicates by properties other than title
  • Loading branch information
dvanoni committed Jan 15, 2024
1 parent ca644fa commit bd23e84
Show file tree
Hide file tree
Showing 25 changed files with 311 additions and 123 deletions.
2 changes: 1 addition & 1 deletion src/content/data/__tests__/item-data.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createZoteroItemMock } from '../../../../test/utils/zotero-mock';
import { createZoteroItemMock } from '../../../../test/utils';
import { getSyncedNotesFromAttachment } from '../item-data';

describe('getSyncedNotesFromAttachment', () => {
Expand Down
24 changes: 23 additions & 1 deletion src/content/notero.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Client } from '@notionhq/client';

import { IS_ZOTERO_7 } from './constants';
import type { PluginInfo } from './plugin-info';
import {
Expand All @@ -8,7 +10,10 @@ import {
Service,
SyncManager,
UIManager,
WindowManager,
} from './services';
import { findDuplicates } from './sync/find-duplicates';
import { getNotionClient } from './sync/notion-client';
import { log } from './utils';

if (!IS_ZOTERO_7) {
Expand All @@ -17,16 +22,19 @@ if (!IS_ZOTERO_7) {

export class Notero {
private readonly eventManager: EventManager;
private readonly windowManager: WindowManager;
private readonly services: Service[];

public constructor() {
this.eventManager = new EventManager();
this.windowManager = new WindowManager();

this.services = [
...(IS_ZOTERO_7
? [new ChromeManager(), new PreferencePaneManager()]
: [new DefaultPreferencesLoader()]),
this.eventManager,
this.windowManager,
new SyncManager(),
new UIManager(),
];
Expand All @@ -45,7 +53,10 @@ export class Notero {
}

private startServices(pluginInfo: PluginInfo) {
const dependencies = { eventManager: this.eventManager };
const dependencies = {
eventManager: this.eventManager,
windowManager: this.windowManager,
};

this.services.forEach((service) => {
log(`Starting ${service.constructor.name}`);
Expand Down Expand Up @@ -90,6 +101,17 @@ export class Notero {
service.removeFromWindow(window);
});
}

public getNotionClient(): Client {
const latestWindow = this.windowManager.getLatestWindow();
if (!latestWindow) throw new Error('No window available');

return getNotionClient(latestWindow);
}

public findDuplicates(propertyName: string = 'title'): Promise<Set<string>> {
return findDuplicates(this.getNotionClient(), propertyName);
}
}

(Zotero as Zotero & { Notero: Notero }).Notero = new Notero();
12 changes: 12 additions & 0 deletions src/content/prefs/notero-pref.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getLocalizedString } from '../utils';

export enum NoteroPref {
collectionSyncConfigs = 'collectionSyncConfigs',
notionDatabaseID = 'notionDatabaseID',
Expand Down Expand Up @@ -84,6 +86,16 @@ export function getNoteroPref<P extends NoteroPref>(
return convertRawPrefValue(pref, value);
}

export function getRequiredNoteroPref<P extends NoteroPref>(
pref: P,
): NonNullable<NoteroPrefValue[P]> {
const value = getNoteroPref(pref);

if (value) return value;

throw new Error(`Missing ${getLocalizedString(pref)}`);
}

export function setNoteroPref<P extends NoteroPref>(
pref: P,
value: NoteroPrefValue[P],
Expand Down
8 changes: 5 additions & 3 deletions src/content/prefs/preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ class Preferences {
public async init(): Promise<void> {
await Zotero.uiReadyPromise;

this.notionDatabaseError = getXULElementById('notero-notionDatabaseError');
this.notionDatabaseMenu = getXULElementById('notero-notionDatabase');
this.pageTitleFormatMenu = getXULElementById('notero-pageTitleFormat');
/* eslint-disable @typescript-eslint/no-non-null-assertion */
this.notionDatabaseError = getXULElementById('notero-notionDatabaseError')!;
this.notionDatabaseMenu = getXULElementById('notero-notionDatabase')!;
this.pageTitleFormatMenu = getXULElementById('notero-pageTitleFormat')!;
/* eslint-enable @typescript-eslint/no-non-null-assertion */

this.prefObserverSymbol = registerNoteroPrefObserver(
NoteroPref.notionToken,
Expand Down
43 changes: 16 additions & 27 deletions src/content/services/__tests__/sync-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { JSDOM } from 'jsdom';
import { mock } from 'jest-mock-extended';

import {
createWindowMock,
createZoteroCollectionMock,
createZoteroItemMock,
mockZoteroPrefs,
} from '../../../../test/utils/zotero-mock';
} from '../../../../test/utils';
import { getSyncedNotes } from '../../data/item-data';
import { saveSyncConfigs } from '../../prefs/collection-sync-config';
import { NoteroPref, setNoteroPref } from '../../prefs/notero-pref';
import { performSyncJob } from '../../sync/sync-job';
import { parseItemDate } from '../../utils';
import { EventManager, SyncManager } from '../index';
import { EventManager, SyncManager, WindowManager } from '../index';

jest.mock('../../data/item-data');
jest.mock('../../sync/sync-job');
Expand Down Expand Up @@ -122,11 +123,6 @@ mockedGetSyncedNotes.mockReturnValue({

const fakeTagID = 1234;

function createWindowMock(): Zotero.ZoteroWindow {
const dom = new JSDOM();
return dom.window as unknown as Zotero.ZoteroWindow;
}

function setup({
collectionSyncEnabled = true,
syncNotes = true,
Expand All @@ -140,20 +136,20 @@ function setup({

const eventManager = new EventManager();
const syncManager = new SyncManager();
const window = createWindowMock();
const windowManager = mock<WindowManager>();

const dependencies = { eventManager };
const dependencies = { eventManager, windowManager };

syncManager.startup({ dependencies, pluginInfo });

syncManager.addToWindow(window);
windowManager.getLatestWindow.mockReturnValue(createWindowMock());

saveSyncConfigs({ [collection.id]: { syncEnabled: collectionSyncEnabled } });

setNoteroPref(NoteroPref.syncNotes, syncNotes);
setNoteroPref(NoteroPref.syncOnModifyItems, syncOnModifyItems);

return { eventManager, syncManager, window };
return { eventManager, windowManager };
}

beforeEach(() => {
Expand All @@ -167,9 +163,9 @@ afterEach(() => {

describe('SyncManager', () => {
it('does not perform sync when window is not available', () => {
const { eventManager, syncManager, window } = setup();
const { eventManager, windowManager } = setup();

syncManager.removeFromWindow(window);
windowManager.getLatestWindow.mockReturnValue(undefined);

eventManager.emit('request-sync-items', [regularItem]);

Expand All @@ -179,30 +175,23 @@ describe('SyncManager', () => {
});

it('performs sync using the latest available window', async () => {
const { eventManager, syncManager, window: window1 } = setup();

const window2 = createWindowMock();
const window3 = createWindowMock();
const window4 = createWindowMock();
const { eventManager, windowManager } = setup();

syncManager.removeFromWindow(window1);
syncManager.addToWindow(window2);
syncManager.addToWindow(window3);
syncManager.addToWindow(window4);
syncManager.removeFromWindow(window3);
const firstWindow = windowManager.getLatestWindow();

eventManager.emit('request-sync-items', [regularItem]);
await jest.runAllTimersAsync();

expect(mockedPerformSyncJob.mock.lastCall?.[1]).toBe(window4);
expect(mockedPerformSyncJob.mock.lastCall?.[1]).toBe(firstWindow);

syncManager.removeFromWindow(window4);
const secondWindow = createWindowMock();
windowManager.getLatestWindow.mockReturnValue(secondWindow);

eventManager.emit('request-sync-items', [regularItem]);
await jest.runAllTimersAsync();

expect(mockedPerformSyncJob).toHaveBeenCalledTimes(2);
expect(mockedPerformSyncJob.mock.lastCall?.[1]).toBe(window2);
expect(mockedPerformSyncJob.mock.lastCall?.[1]).toBe(secondWindow);
});

describe('receiving `request-sync-collection` event', () => {
Expand Down
40 changes: 40 additions & 0 deletions src/content/services/__tests__/window-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createWindowMock } from '../../../../test/utils/window-mock';
import { WindowManager } from '../window-manager';

describe('WindowManager.getLatestWindow', () => {
it('returns the latest available window', () => {
const windowManager = new WindowManager();

const window1 = createWindowMock();
const window2 = createWindowMock();
const window3 = createWindowMock();

windowManager.addToWindow(window1);
windowManager.addToWindow(window2);
windowManager.addToWindow(window3);
windowManager.removeFromWindow(window2);

expect(windowManager.getLatestWindow()).toBe(window3);

windowManager.removeFromWindow(window3);

expect(windowManager.getLatestWindow()).toBe(window1);
});

it('returns undefined when all windows have been removed', () => {
const windowManager = new WindowManager();

const window1 = createWindowMock();
const window2 = createWindowMock();

windowManager.addToWindow(window1);
windowManager.addToWindow(window2);
windowManager.removeFromWindow(window1);

expect(windowManager.getLatestWindow()).toBe(window2);

windowManager.removeFromWindow(window2);

expect(windowManager.getLatestWindow()).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions src/content/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { EventManager } from './event-manager';
export { PreferencePaneManager } from './preference-pane-manager';
export { SyncManager } from './sync-manager';
export { UIManager } from './ui-manager';
export { WindowManager } from './window-manager';
2 changes: 2 additions & 0 deletions src/content/services/service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { PluginInfo } from '../plugin-info';

import type { EventManager } from './event-manager';
import type { WindowManager } from './window-manager';

type Dependencies = {
eventManager: EventManager;
windowManager: WindowManager;
};

export type ServiceParams = {
Expand Down
31 changes: 9 additions & 22 deletions src/content/services/sync-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getAllCollectionItems, log, parseItemDate } from '../utils';

import type { EventManager, NotifierEventParams } from './event-manager';
import type { Service, ServiceParams } from './service';
import type { WindowManager } from './window-manager';

const SYNC_DEBOUNCE_MS = 2000;

Expand All @@ -16,15 +17,17 @@ type QueuedSync = {

export class SyncManager implements Service {
private eventManager!: EventManager;
private windowManager!: WindowManager;

private queuedSync?: QueuedSync;

private syncInProgress = false;

private readonly windows: Zotero.ZoteroWindow[] = [];

public startup({ dependencies: { eventManager } }: ServiceParams) {
public startup({
dependencies: { eventManager, windowManager },
}: ServiceParams) {
this.eventManager = eventManager;
this.windowManager = windowManager;

const { addListener } = eventManager;

Expand All @@ -41,23 +44,6 @@ export class SyncManager implements Service {
removeListener('request-sync-items', this.handleSyncItems);
}

public addToWindow(window: Zotero.ZoteroWindow) {
if (!this.windows.includes(window)) {
this.windows.unshift(window);
}
}

public removeFromWindow(window: Zotero.ZoteroWindow) {
const index = this.windows.indexOf(window);
if (index >= 0) {
this.windows.splice(index, 1);
}
}

private get latestWindow(): Zotero.ZoteroWindow | undefined {
return this.windows[0];
}

private handleNotifierEvent = (...params: NotifierEventParams) => {
const items = this.getItemsForNotifierEvent(...params);
if (!items.length) return;
Expand Down Expand Up @@ -252,13 +238,14 @@ export class SyncManager implements Service {
}

private async performSync() {
if (!this.queuedSync || !this.latestWindow) return;
const latestWindow = this.windowManager.getLatestWindow();
if (!this.queuedSync || !latestWindow) return;

const { itemIDs } = this.queuedSync;
this.queuedSync = undefined as QueuedSync | undefined;
this.syncInProgress = true;

await performSyncJob(itemIDs, this.latestWindow);
await performSyncJob(itemIDs, latestWindow);

if (this.queuedSync && !this.queuedSync.timeoutID) {
await this.performSync();
Expand Down
24 changes: 24 additions & 0 deletions src/content/services/window-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Service } from './service';

export class WindowManager implements Service {
private readonly windows: Zotero.ZoteroWindow[] = [];

public startup() {}

public addToWindow(window: Zotero.ZoteroWindow) {
if (!this.windows.includes(window)) {
this.windows.unshift(window);
}
}

public removeFromWindow(window: Zotero.ZoteroWindow) {
const index = this.windows.indexOf(window);
if (index >= 0) {
this.windows.splice(index, 1);
}
}

public getLatestWindow(): Zotero.ZoteroWindow | undefined {
return this.windows[0];
}
}
Loading

0 comments on commit bd23e84

Please sign in to comment.