Skip to content

Commit

Permalink
Merge pull request #1371 from cdrini/refactor/br-plugin
Browse files Browse the repository at this point in the history
Introduce BookReaderPlugin abstraction and apply to ArchiveAnalyticsPlugin
  • Loading branch information
cdrini authored Jan 30, 2025
2 parents 5c9f6d8 + 83c71e6 commit 4bf48dc
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 92 deletions.
49 changes: 49 additions & 0 deletions src/BookReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ BookReader.constMode1up = 1;
BookReader.constMode2up = 2;
/** thumbnails view */
BookReader.constModeThumb = 3;

// Although this can actualy have any BookReaderPlugin subclass as value, we
// hardcode the known plugins here for type checking
BookReader.PLUGINS = {
/** @type {typeof import('./plugins/plugin.archive_analytics.js').ArchiveAnalyticsPlugin | null}*/
archiveAnalytics: null,
};

/**
* @param {string} pluginName
* @param {typeof import('./BookReaderPlugin.js').BookReaderPlugin} plugin
*/
BookReader.registerPlugin = function(pluginName, plugin) {
if (BookReader.PLUGINS[pluginName]) {
console.warn(`Plugin ${pluginName} already registered. Overwriting.`);
}
BookReader.PLUGINS[pluginName] = plugin;
};

/** image cache */
BookReader.imageCache = null;

Expand Down Expand Up @@ -237,6 +256,26 @@ BookReader.prototype.setup = function(options) {
'_modes.modeThumb': this._modes.modeThumb,
};

// Construct the usual suspects first to get type hints
this._plugins = {
archiveAnalytics: BookReader.PLUGINS.archiveAnalytics ? new BookReader.PLUGINS.archiveAnalytics(this) : null,
};

// Now construct the rest of the plugins
for (const [pluginName, PluginClass] of Object.entries(BookReader.PLUGINS)) {
if (this._plugins[pluginName] || !PluginClass) continue;
this._plugins[pluginName] = new PluginClass(this);
}

// And call setup on them
for (const [pluginName, plugin] of Object.entries(this._plugins)) {
try {
plugin.setup(this.options.plugins?.[pluginName] ?? {});
} catch (e) {
console.error(`Error setting up plugin ${pluginName}`, e);
}
}

/** Image cache for general image fetching */
this.imageCache = new ImageCache(this.book, {
useSrcSet: this.options.useSrcSet,
Expand Down Expand Up @@ -585,6 +624,16 @@ BookReader.prototype.init = function() {
this.enterFullscreen(true);
}

// Init plugins
for (const [pluginName, plugin] of Object.entries(this._plugins)) {
try {
plugin.init();
}
catch (e) {
console.error(`Error initializing plugin ${pluginName}`, e);
}
}

this.init.initComplete = true;
this.trigger(BookReader.eventNames.PostInit);

Expand Down
3 changes: 3 additions & 0 deletions src/BookReader/options.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
/** @typedef {import('./BookModel.js').PageNumString} PageNumString */
/** @typedef {import('./BookModel.js').LeafNum} LeafNum */

Expand Down Expand Up @@ -140,6 +141,8 @@ export const DEFAULT_OPTIONS = {
* but going forward we'll keep them here.
**/
plugins: {
/** @type {import('../plugins/plugin.archive_analytics.js').ArchiveAnalyticsPlugin['options']}*/
archiveAnalytics: null,
/** @type {import('../plugins/plugin.text_selection.js').TextSelectionPluginOptions} */
textSelection: null,
},
Expand Down
28 changes: 28 additions & 0 deletions src/BookReaderPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// @ts-check
/** @typedef {import("./BookReader.js").default} BookReader */

/**
* @template TOptions
*/
export class BookReaderPlugin {
/**
* @param {BookReader} br
*/
constructor(br) {
/** @type {BookReader} */
this.br = br;
/** @type {TOptions} */
this.options;
}

/**
* @abstract
* @param {TOptions} options
**/
setup(options) {
this.options = Object.assign({}, this.options, options);
}

/** @abstract */
init() {}
}
162 changes: 84 additions & 78 deletions src/plugins/plugin.archive_analytics.js
Original file line number Diff line number Diff line change
@@ -1,86 +1,92 @@
/* global BookReader */
/**
* Plugin for Archive.org analytics
*/
jQuery.extend(BookReader.defaultOptions, {
enableArchiveAnalytics: true,
/** Provide a means of debugging, cause otherwise it's impossible to test locally */
debugArchiveAnaltyics: false,
});

BookReader.prototype.init = (function(super_) {
return function() {
super_.call(this);

if (this.options.enableArchiveAnalytics) {
this.bind(BookReader.eventNames.fragmentChange, () => this.archiveAnalyticsSendFragmentChange());
}
};
})(BookReader.prototype.init);
// @ts-check
import { BookReaderPlugin } from "../BookReaderPlugin.js";

const BookReader = /** @type {typeof import('../BookReader').default} */(window.BookReader);


/** @private */
BookReader.prototype.archiveAnalyticsSendFragmentChange = function() {
if (!window.archive_analytics) {
return;
export class ArchiveAnalyticsPlugin extends BookReaderPlugin {
options = {
enabled: true,
/** Provide a means of debugging, cause otherwise it's impossible to test locally */
debug: false,
}

const prevFragment = this.archiveAnalyticsSendFragmentChange.prevFragment;

const params = this.paramsFromCurrent();
const newFragment = this.fragmentFromParams(params);

if (prevFragment != newFragment) {
const values = {
bookreader: "user_changed_view",
itemid: this.bookId,
cache_bust: Math.random(),
};
// EEK! offsite embedding and /details/ page books look the same in analytics, otherwise!
values.offsite = 1;
values.details = 0;
try {
values.offsite = window.top.location.hostname.match(/\.archive.org$/)
? 0
: 1;
values.details =
!values.offsite && window.top.location.pathname.match(/^\/details\//)
? 1
: 0;
} catch (e) {}
// avoids embed cross site exceptions -- but on (+) side, means it is and keeps marked offite!

// Send bookreader ping
window.archive_analytics.send_ping(values, null, "augment_for_ao_site");

// Also send tracking event ping
const additionalEventParams = this.options.lendingInfo && this.options.lendingInfo.loanId
? { loanId: this.options.lendingInfo.loanId }
: {};
window.archive_analytics.send_event('BookReader', 'UserChangedView', window.location.pathname, additionalEventParams);

this.archiveAnalyticsSendFragmentChange.prevFragment = newFragment;
/** @type {string} */
_prevFragment = null;

/** @override */
init() {
if (this.options.enabled) {
this.br.bind(BookReader.eventNames.fragmentChange, () => this.sendFragmentChange());
}
}
};

/**
* Sends a tracking "Event". See https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#events
* @param {string} category
* @param {string} action
* @param {number} [value] (must be an int)
* @param {Object} [additionalEventParams]
*/
BookReader.prototype.archiveAnalyticsSendEvent = function(category, action, value, additionalEventParams) {
if (!this.options.enableArchiveAnalytics) return;

if (this.options.debugArchiveAnaltyics) {
console.log("archiveAnalyticsSendEvent", arguments, window.archive_analytics);

/** @private */
sendFragmentChange() {
if (!window.archive_analytics) {
return;
}

const prevFragment = this._prevFragment;

const params = this.br.paramsFromCurrent();
const newFragment = this.br.fragmentFromParams(params);

if (prevFragment != newFragment) {
const values = {
bookreader: "user_changed_view",
itemid: this.br.bookId,
cache_bust: Math.random(),
};
// EEK! offsite embedding and /details/ page books look the same in analytics, otherwise!
values.offsite = 1;
values.details = 0;
try {
values.offsite = window.top.location.hostname.match(/\.archive.org$/)
? 0
: 1;
values.details =
!values.offsite && window.top.location.pathname.match(/^\/details\//)
? 1
: 0;
} catch (e) { }
// avoids embed cross site exceptions -- but on (+) side, means it is and keeps marked offite!

// Send bookreader ping
window.archive_analytics.send_ping(values, null, "augment_for_ao_site");

// Also send tracking event ping
const additionalEventParams = this.br.options.lendingInfo?.loanId
? { loanId: this.br.options.lendingInfo.loanId }
: {};
window.archive_analytics.send_event('BookReader', 'UserChangedView', window.location.pathname, additionalEventParams);

this._prevFragment = newFragment;
}
}

if (!window.archive_analytics) return;
/**
* Sends a tracking "Event". See https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#events
* @param {string} category
* @param {string} action
* @param {number} [value] (must be an int)
* @param {Object} [additionalEventParams]
*/
sendEvent(category, action, value, additionalEventParams) {
if (!this.options.enabled) return;

additionalEventParams = additionalEventParams || {};
if (typeof(value) == 'number') {
additionalEventParams.ev = value;
if (this.options.debug) {
console.log("archiveAnalyticsSendEvent", arguments, window.archive_analytics);
}

if (!window.archive_analytics) return;

additionalEventParams = additionalEventParams || {};
if (typeof (value) == 'number') {
additionalEventParams.ev = value;
}
window.archive_analytics.send_event(category, action, null, additionalEventParams);
}
window.archive_analytics.send_event(category, action, null, additionalEventParams);
};
}

BookReader?.registerPlugin('archiveAnalytics', ArchiveAnalyticsPlugin);
2 changes: 1 addition & 1 deletion src/plugins/plugin.text_selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export class BookreaderWithTextSelection extends BookReader {
new SelectionObserver('.BRtextLayer', (selectEvent) => {
// Track how often selection is used
if (selectEvent == 'started') {
this.archiveAnalyticsSendEvent?.('BookReader', 'SelectStart');
this._plugins.archiveAnalytics?.sendEvent('BookReader', 'SelectStart');

// Set a class on the page to avoid hiding it when zooming/etc
this.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/tts/plugin.tts.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,10 @@ BookReader.prototype.ttsRemoveHilites = function () {
* @param {number} [value]
*/
BookReader.prototype.ttsSendAnalyticsEvent = function(action, value) {
if (this.archiveAnalyticsSendEvent) {
if (this._plugins.archiveAnalytics) {
const extraValues = {};
const mediaLanguage = this.ttsEngine.opts.bookLanguage;
if (mediaLanguage) extraValues.mediaLanguage = mediaLanguage;
this.archiveAnalyticsSendEvent('BRReadAloud', action, value, extraValues);
this._plugins.archiveAnalytics.sendEvent('BRReadAloud', action, value, extraValues);
}
};
19 changes: 8 additions & 11 deletions tests/jest/plugins/plugin.archive_analytics.test.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import sinon from 'sinon';
import BookReader from '@/src/BookReader.js';
import '@/src/plugins/plugin.archive_analytics.js';

describe('archiveAnalyticsSendEvent', () => {
const sendEvent = BookReader.prototype.archiveAnalyticsSendEvent;
import {ArchiveAnalyticsPlugin} from '@/src/plugins/plugin.archive_analytics.js';

describe('sendEvent', () => {
test('logs if debug set to true', () => {
const stub = sinon.stub(console, 'log');
const FAKE_BR = { options: { enableArchiveAnalytics: true, debugArchiveAnaltyics: true }};
sendEvent.call(FAKE_BR);
const p = new ArchiveAnalyticsPlugin({});
p.setup({ debug: true });
p.sendEvent();
expect(stub.callCount).toBe(1);
stub.restore();
});

test('Does not error if window.archive_analytics is undefined', () => {
const spy = sinon.spy(sendEvent);
const FAKE_BR = { options: { enableArchiveAnalytics: true }};
spy.call(FAKE_BR);
const p = new ArchiveAnalyticsPlugin({});
const spy = sinon.spy(p.sendEvent);
p.sendEvent();
expect(spy.threw()).toBe(false);
});
});

0 comments on commit 4bf48dc

Please sign in to comment.