[0]): void;
20 | function defineGlobal(name: string, getter: () => any): void;
21 | function defineGlobal(name: string, getter?: () => any) {
22 | Object.defineProperty(_globalThis, name, {
23 | get() {
24 | return getter ? getter() : basicTool.getGlobal(name);
25 | },
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/src/modules/Common.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../../package.json";
2 | import { getString } from "../utils/locale";
3 | import { tldrs } from "./dataStorage";
4 |
5 | export class RegisterFactory {
6 | // 注册zotero的通知
7 | static registerNotifier() {
8 | const callback = {
9 | notify: async (
10 | event: string,
11 | type: string,
12 | ids: number[] | string[],
13 | extraData: { [key: string]: any },
14 | ) => {
15 | if (!addon?.data.alive) {
16 | this.unregisterNotifier(notifierID);
17 | return;
18 | }
19 | addon.hooks.onNotify(event, type, ids, extraData);
20 | },
21 | };
22 |
23 | // Register the callback in Zotero as an item observer
24 | const notifierID = Zotero.Notifier.registerObserver(callback, ["item"]);
25 |
26 | // Unregister callback when the window closes (important to avoid a memory leak)
27 | window.addEventListener(
28 | "unload",
29 | (e: Event) => {
30 | this.unregisterNotifier(notifierID);
31 | },
32 | false,
33 | );
34 | }
35 |
36 | private static unregisterNotifier(notifierID: string) {
37 | Zotero.Notifier.unregisterObserver(notifierID);
38 | }
39 | }
40 |
41 | export class UIFactory {
42 | // item右键菜单
43 | static registerRightClickMenuItem() {
44 | const menuIcon = `chrome://${config.addonRef}/content/icons/favicon.png`;
45 | // item menuitem with icon
46 | ztoolkit.Menu.register("item", {
47 | tag: "menuitem",
48 | id: "zotero-itemmenu-tldr",
49 | label: getString("menuitem-updatetldrlabel"),
50 | commandListener: (ev) => {
51 | const selectedItems = ZoteroPane.getSelectedItems() ?? [];
52 | addon.hooks.onUpdateItems(selectedItems, selectedItems.length <= 1);
53 | },
54 | icon: menuIcon,
55 | });
56 | }
57 |
58 | // collection右键菜单
59 | static registerRightClickCollectionMenuItem() {
60 | const menuIcon = `chrome://${config.addonRef}/content/icons/favicon.png`;
61 | ztoolkit.Menu.register("collection", {
62 | tag: "menuitem",
63 | id: "zotero-collectionmenu-tldr",
64 | label: getString("menucollection-updatetldrlabel"),
65 | commandListener: (ev) =>
66 | addon.hooks.onUpdateItems(
67 | ZoteroPane.getSelectedCollection()?.getChildItems() ?? [],
68 | false,
69 | ),
70 | icon: menuIcon,
71 | });
72 | }
73 |
74 | // tldr行
75 | static async registerTLDRItemBoxRow() {
76 | const itemTLDR = (item: Zotero.Item) => {
77 | const noteKey = tldrs.get()[item.key];
78 | if (noteKey) {
79 | const obj = Zotero.Items.getByLibraryAndKey(item.libraryID, noteKey);
80 | if (
81 | obj &&
82 | obj instanceof Zotero.Item &&
83 | item.getNotes().includes(obj.id)
84 | ) {
85 | let str = obj.getNote();
86 | if (str.startsWith("TL;DR
\n")) {
87 | str = str.slice("
TL;DR
\n".length);
88 | }
89 | if (str.endsWith("
")) {
90 | str = str.slice(0, -4);
91 | }
92 | return str;
93 | }
94 | }
95 | return "";
96 | };
97 | Zotero.ItemPaneManager.registerSection({
98 | paneID: config.addonRef,
99 | pluginID: config.addonID,
100 | header: {
101 | l10nID: `${config.addonRef}-itemPaneSection-header`,
102 | icon: `chrome://${config.addonRef}/content/icons/favicon@16.png`,
103 | },
104 | sidenav: {
105 | l10nID: `${config.addonRef}-itemPaneSection-sidenav`,
106 | icon: `chrome://${config.addonRef}/content/icons/favicon@20.png`,
107 | },
108 | onRender: ({ body, item }: any) => {
109 | let tldr = itemTLDR(item);
110 | if (tldr.length <= 0 && item.parentItem) {
111 | tldr = itemTLDR(item.parentItem);
112 | }
113 | body.textContent = tldr;
114 | },
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/modules/dataStorage.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../../package.json";
2 |
3 | export class Data {
4 | [x: string]: any;
5 | private dataType: string;
6 | private filePath?: string;
7 | private _data: Record;
8 |
9 | constructor(dataType: string) {
10 | this.dataType = dataType;
11 | this._data = {} as Record;
12 | }
13 |
14 | async getAsync() {
15 | await this.initDataIfNeed();
16 | return this.data;
17 | }
18 |
19 | get() {
20 | return this.data;
21 | }
22 |
23 | async modify(
24 | action: (data: Record) => Record | Promise>,
25 | ) {
26 | await this.initDataIfNeed();
27 | const data = this.data;
28 | const newData = await action(data);
29 | if (this.filePath) {
30 | try {
31 | await IOUtils.writeJSON(this.filePath, newData, {
32 | mode: "overwrite",
33 | compress: false,
34 | });
35 | this.data = newData;
36 | return newData;
37 | } catch (error) {
38 | return data;
39 | }
40 | } else {
41 | this.data = newData;
42 | return newData;
43 | }
44 | }
45 |
46 | async delete() {
47 | if (this.filePath) {
48 | try {
49 | await IOUtils.remove(this.filePath);
50 | this.data = {} as Record;
51 | return true;
52 | } catch (error) {
53 | return false;
54 | }
55 | } else {
56 | this.data = {} as Record;
57 | return true;
58 | }
59 | }
60 |
61 | private get data() {
62 | return this._data;
63 | }
64 |
65 | private set data(value: Record) {
66 | this._data = value;
67 | }
68 |
69 | private async initDataIfNeed() {
70 | if (this.inited) {
71 | return;
72 | }
73 | this.inited = true;
74 |
75 | const prefsFile = PathUtils.join(PathUtils.profileDir, "prefs.js");
76 | const prefs = await Zotero.Profile.readPrefsFromFile(prefsFile);
77 | let dir = prefs["extensions.zotero.dataDir"];
78 | if (dir) {
79 | dir = PathUtils.join(dir, config.addonName);
80 | } else {
81 | dir = PathUtils.join(
82 | PathUtils.profileDir,
83 | "extensions",
84 | config.addonName,
85 | );
86 | }
87 | IOUtils.makeDirectory(dir, {
88 | createAncestors: true,
89 | ignoreExisting: true,
90 | });
91 | this.filePath = PathUtils.join(dir, this.dataType);
92 | try {
93 | this.data = await IOUtils.readJSON(this.filePath, { decompress: false });
94 | } catch (error) {
95 | this.data = {} as Record;
96 | }
97 | }
98 | }
99 |
100 | export class DataStorage {
101 | private dataMap: { [key: string]: Data } = {};
102 |
103 | private static shared = new DataStorage();
104 |
105 | static instance(
106 | dataType: string,
107 | ): Data {
108 | if (this.shared.dataMap[dataType] === undefined) {
109 | const data = new Data(dataType);
110 | this.shared.dataMap[dataType] = data;
111 | return data;
112 | } else {
113 | return this.shared.dataMap[dataType];
114 | }
115 | }
116 |
117 | private constructor() {
118 | // empty
119 | }
120 | }
121 |
122 | export const tldrs = DataStorage.instance(
123 | "fetchedItems.json",
124 | );
125 |
--------------------------------------------------------------------------------
/src/modules/preferenceScript.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../../package.json";
2 | import { getString } from "../utils/locale";
3 |
4 | export async function registerPrefsScripts(_window: Window) {
5 | // This function is called when the prefs window is opened
6 | // See addon/chrome/content/preferences.xul onpaneload
7 | if (!addon.data.prefs) {
8 | addon.data.prefs = {
9 | window: _window,
10 | };
11 | } else {
12 | addon.data.prefs.window = _window;
13 | }
14 | bindPrefEvents();
15 | }
16 |
17 | function bindPrefEvents() {}
18 |
--------------------------------------------------------------------------------
/src/modules/tldrFetcher.ts:
--------------------------------------------------------------------------------
1 | import { tldrs } from "./dataStorage";
2 |
3 | type SemanticScholarItemInfo = {
4 | title?: string;
5 | abstract?: string;
6 | tldr?: string;
7 | };
8 |
9 | export class TLDRFetcher {
10 | private readonly zoteroItem: Zotero.Item;
11 | private readonly title?: string;
12 | private readonly abstract?: string;
13 |
14 | constructor(item: Zotero.Item) {
15 | this.zoteroItem = item;
16 | if (item.isRegularItem()) {
17 | this.title = item.getField("title") as string;
18 | this.abstract = item.getField("abstractNote") as string;
19 | }
20 | }
21 |
22 | async fetchTLDR() {
23 | if (!this.title || this.title.length <= 0) {
24 | return false;
25 | }
26 | const noteKey = (await tldrs.getAsync())[this.zoteroItem.key];
27 | try {
28 | const infos = await this.fetchRelevanceItemInfos(this.title);
29 | for (const info of infos) {
30 | let match = false;
31 | if (info.title && this.title && this.checkLCS(info.title, this.title)) {
32 | match = true;
33 | } else if (
34 | info.abstract &&
35 | this.abstract &&
36 | this.checkLCS(info.abstract, this.abstract)
37 | ) {
38 | match = true;
39 | }
40 | if (match && info.tldr) {
41 | let note = new Zotero.Item("note");
42 | if (noteKey) {
43 | const obj = Zotero.Items.getByLibraryAndKey(
44 | this.zoteroItem.libraryID,
45 | noteKey,
46 | );
47 | if (
48 | obj &&
49 | obj instanceof Zotero.Item &&
50 | this.zoteroItem.getNotes().includes(obj.id)
51 | ) {
52 | note = obj;
53 | }
54 | }
55 | note.setNote(`TL;DR
\n${info.tldr}
`);
56 | note.parentID = this.zoteroItem.id;
57 | await note.saveTx();
58 | await tldrs.modify((data: any) => {
59 | data[this.zoteroItem.key] = note.key;
60 | return data;
61 | });
62 | return true;
63 | }
64 | }
65 | await tldrs.modify((data: any) => {
66 | data[this.zoteroItem.key] = false;
67 | return data;
68 | });
69 | } catch (error) {
70 | Zotero.log(`post semantic scholar request error: ${error}`);
71 | }
72 | }
73 |
74 | private async fetchRelevanceItemInfos(
75 | title: string,
76 | ): Promise {
77 | const semanticScholarURL = "https://www.semanticscholar.org/api/1/search";
78 | const params = {
79 | queryString: title,
80 | page: 1,
81 | pageSize: 10,
82 | sort: "relevance",
83 | authors: [],
84 | coAuthors: [],
85 | venues: [],
86 | performTitleMatch: true,
87 | requireViewablePdf: false,
88 | includeTldrs: true,
89 | };
90 | const resp = await Zotero.HTTP.request("POST", semanticScholarURL, {
91 | headers: { "Content-Type": "application/json" },
92 | body: JSON.stringify(params),
93 | });
94 | if (resp.status === 200) {
95 | const results = JSON.parse(resp.response).results;
96 | return results.map((item: any) => {
97 | const result = {
98 | title: item.title.text,
99 | abstract: item.paperAbstract.text,
100 | tldr: undefined,
101 | };
102 | if (item.tldr) {
103 | result.tldr = item.tldr.text;
104 | }
105 | return result;
106 | });
107 | }
108 | return [];
109 | }
110 |
111 | private checkLCS(pattern: string, content: string): boolean {
112 | const LCS = StringMatchUtils.longestCommonSubsequence(pattern, content);
113 | return LCS.length >= Math.max(pattern.length, content.length) * 0.9;
114 | }
115 | }
116 |
117 | class StringMatchUtils {
118 | static longestCommonSubsequence(text1: string, text2: string): string {
119 | const m = text1.length;
120 | const n = text2.length;
121 |
122 | const dp: number[][] = new Array(m + 1);
123 | for (let i = 0; i <= m; i++) {
124 | dp[i] = new Array(n + 1).fill(0);
125 | }
126 |
127 | for (let i = 1; i <= m; i++) {
128 | for (let j = 1; j <= n; j++) {
129 | if (text1[i - 1] === text2[j - 1]) {
130 | dp[i][j] = dp[i - 1][j - 1] + 1;
131 | } else {
132 | dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
133 | }
134 | }
135 | }
136 |
137 | let i = m,
138 | j = n;
139 | const lcs: string[] = [];
140 | while (i > 0 && j > 0) {
141 | if (text1[i - 1] === text2[j - 1]) {
142 | lcs.unshift(text1[i - 1]);
143 | i--;
144 | j--;
145 | } else if (dp[i - 1][j] > dp[i][j - 1]) {
146 | i--;
147 | } else {
148 | j--;
149 | }
150 | }
151 |
152 | return lcs.join("");
153 | }
154 |
155 | // static minWindow(s: string, t: string): [number, number] | null {
156 | // const m = s.length, n = t.length
157 | // let start = -1, minLen = Number.MAX_SAFE_INTEGER, i = 0, j = 0, end;
158 | // while (i < m) {
159 | // if (s[i] == t[j]) {
160 | // if (++j == n) {
161 | // end = i + 1;
162 | // while (--j >= 0) {
163 | // while (s[i--] != t[j]);
164 | // }
165 | // ++i; ++j;
166 | // if (end - i < minLen) {
167 | // minLen = end - i;
168 | // start = i;
169 | // }
170 | // }
171 | // }
172 | // ++i;
173 | // }
174 | // return start == -1 ? null : [start, minLen];
175 | // }
176 | }
177 |
--------------------------------------------------------------------------------
/src/utils/locale.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../../package.json";
2 |
3 | export { initLocale, getString };
4 |
5 | /**
6 | * Initialize locale data
7 | */
8 | function initLocale() {
9 | const l10n = new (
10 | typeof Localization === "undefined"
11 | ? ztoolkit.getGlobal("Localization")
12 | : Localization
13 | )([`${config.addonRef}-addon.ftl`], true);
14 | addon.data.locale = {
15 | current: l10n,
16 | };
17 | }
18 |
19 | /**
20 | * Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl
21 | * @param localString ftl key
22 | * @param options.branch branch name
23 | * @param options.args args
24 | * @example
25 | * ```ftl
26 | * # addon.ftl
27 | * addon-static-example = This is default branch!
28 | * .branch-example = This is a branch under addon-static-example!
29 | * addon-dynamic-example =
30 | { $count ->
31 | [one] I have { $count } apple
32 | *[other] I have { $count } apples
33 | }
34 | * ```
35 | * ```js
36 | * getString("addon-static-example"); // This is default branch!
37 | * getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example!
38 | * getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple
39 | * getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples
40 | * ```
41 | */
42 | function getString(localString: string): string;
43 | function getString(localString: string, branch: string): string;
44 | function getString(
45 | localeString: string,
46 | options: { branch?: string | undefined; args?: Record },
47 | ): string;
48 | function getString(...inputs: any[]) {
49 | if (inputs.length === 1) {
50 | return _getString(inputs[0]);
51 | } else if (inputs.length === 2) {
52 | if (typeof inputs[1] === "string") {
53 | return _getString(inputs[0], { branch: inputs[1] });
54 | } else {
55 | return _getString(inputs[0], inputs[1]);
56 | }
57 | } else {
58 | throw new Error("Invalid arguments");
59 | }
60 | }
61 |
62 | function _getString(
63 | localeString: string,
64 | options: { branch?: string | undefined; args?: Record } = {},
65 | ): string {
66 | const localStringWithPrefix = `${config.addonRef}-${localeString}`;
67 | const { branch, args } = options;
68 | const pattern = addon.data.locale?.current.formatMessagesSync([
69 | { id: localStringWithPrefix, args },
70 | ])[0];
71 | if (!pattern) {
72 | return localStringWithPrefix;
73 | }
74 | if (branch && pattern.attributes) {
75 | return pattern.attributes[branch] || localStringWithPrefix;
76 | } else {
77 | return pattern.value || localStringWithPrefix;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/utils/prefs.ts:
--------------------------------------------------------------------------------
1 | import { config } from "../../package.json";
2 |
3 | /**
4 | * Get preference value.
5 | * Wrapper of `Zotero.Prefs.get`.
6 | * @param key
7 | */
8 | export function getPref(key: string) {
9 | return Zotero.Prefs.get(`${config.prefsPrefix}.${key}`, true);
10 | }
11 |
12 | /**
13 | * Set preference value.
14 | * Wrapper of `Zotero.Prefs.set`.
15 | * @param key
16 | * @param value
17 | */
18 | export function setPref(key: string, value: string | number | boolean) {
19 | return Zotero.Prefs.set(`${config.prefsPrefix}.${key}`, value, true);
20 | }
21 |
22 | /**
23 | * Clear preference value.
24 | * Wrapper of `Zotero.Prefs.clear`.
25 | * @param key
26 | */
27 | export function clearPref(key: string) {
28 | return Zotero.Prefs.clear(`${config.prefsPrefix}.${key}`, true);
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/wait.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Wait until the condition is `true` or timeout.
3 | * The callback is triggered if condition returns `true`.
4 | * @param condition
5 | * @param callback
6 | * @param interval
7 | * @param timeout
8 | */
9 | export function waitUntil(
10 | condition: () => boolean,
11 | callback: () => void,
12 | interval = 100,
13 | timeout = 10000,
14 | ) {
15 | const start = Date.now();
16 | const intervalId = ztoolkit.getGlobal("setInterval")(() => {
17 | if (condition()) {
18 | ztoolkit.getGlobal("clearInterval")(intervalId);
19 | callback();
20 | } else if (Date.now() - start > timeout) {
21 | ztoolkit.getGlobal("clearInterval")(intervalId);
22 | }
23 | }, interval);
24 | }
25 |
26 | /**
27 | * Wait async until the condition is `true` or timeout.
28 | * @param condition
29 | * @param interval
30 | * @param timeout
31 | */
32 | export function waitUtilAsync(
33 | condition: () => boolean,
34 | interval = 100,
35 | timeout = 10000,
36 | ) {
37 | return new Promise((resolve, reject) => {
38 | const start = Date.now();
39 | const intervalId = ztoolkit.getGlobal("setInterval")(() => {
40 | if (condition()) {
41 | ztoolkit.getGlobal("clearInterval")(intervalId);
42 | resolve();
43 | } else if (Date.now() - start > timeout) {
44 | ztoolkit.getGlobal("clearInterval")(intervalId);
45 | reject();
46 | }
47 | }, interval);
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/src/utils/window.ts:
--------------------------------------------------------------------------------
1 | export { isWindowAlive };
2 |
3 | /**
4 | * Check if the window is alive.
5 | * Useful to prevent opening duplicate windows.
6 | * @param win
7 | */
8 | function isWindowAlive(win?: Window) {
9 | return win && !Components.utils.isDeadWrapper(win) && !win.closed;
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/ztoolkit.ts:
--------------------------------------------------------------------------------
1 | import ZoteroToolkit from "zotero-plugin-toolkit";
2 | import { config } from "../../package.json";
3 |
4 | export { createZToolkit };
5 |
6 | function createZToolkit() {
7 | const _ztoolkit = new ZoteroToolkit();
8 | /**
9 | * Alternatively, import toolkit modules you use to minify the plugin size.
10 | * You can add the modules under the `MyToolkit` class below and uncomment the following line.
11 | */
12 | // const _ztoolkit = new MyToolkit();
13 | initZToolkit(_ztoolkit);
14 | return _ztoolkit;
15 | }
16 |
17 | function initZToolkit(_ztoolkit: ReturnType) {
18 | const env = __env__;
19 | _ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`;
20 | _ztoolkit.basicOptions.log.disableConsole = env === "production";
21 | _ztoolkit.UI.basicOptions.ui.enableElementJSONLog = __env__ === "development";
22 | _ztoolkit.UI.basicOptions.ui.enableElementDOMLog = __env__ === "development";
23 | _ztoolkit.basicOptions.debug.disableDebugBridgePassword =
24 | __env__ === "development";
25 | _ztoolkit.basicOptions.api.pluginID = config.addonID;
26 | _ztoolkit.ProgressWindow.setIconURI(
27 | "default",
28 | `chrome://${config.addonRef}/content/icons/favicon.png`,
29 | );
30 | }
31 |
32 | import { BasicTool, unregister } from "zotero-plugin-toolkit/dist/basic";
33 | import { UITool } from "zotero-plugin-toolkit/dist/tools/ui";
34 | import { PreferencePaneManager } from "zotero-plugin-toolkit/dist/managers/preferencePane";
35 |
36 | class MyToolkit extends BasicTool {
37 | UI: UITool;
38 | PreferencePane: PreferencePaneManager;
39 |
40 | constructor() {
41 | super();
42 | this.UI = new UITool(this);
43 | this.PreferencePane = new PreferencePaneManager(this);
44 | }
45 |
46 | unregisterAll() {
47 | unregister(this);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "experimentalDecorators": true,
4 | "module": "commonjs",
5 | "target": "ES2016",
6 | "resolveJsonModule": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | },
10 | "include": ["src", "typings", "node_modules/zotero-types"],
11 | "exclude": ["build", "addon"],
12 | }
13 |
--------------------------------------------------------------------------------
/typings/global.d.ts:
--------------------------------------------------------------------------------
1 | declare const _globalThis: {
2 | [key: string]: any;
3 | Zotero: _ZoteroTypes.Zotero;
4 | ZoteroPane: _ZoteroTypes.ZoteroPane;
5 | Zotero_Tabs: typeof Zotero_Tabs;
6 | window: Window;
7 | document: Document;
8 | ztoolkit: ZToolkit;
9 | addon: typeof addon;
10 | };
11 |
12 | declare type ZToolkit = ReturnType<
13 | typeof import("../src/utils/ztoolkit").createZToolkit
14 | >;
15 |
16 | declare const ztoolkit: ZToolkit;
17 |
18 | declare const rootURI: string;
19 |
20 | declare const addon: import("../src/addon").default;
21 |
22 | declare const __env__: "production" | "development";
23 |
24 | declare class Localization {}
25 |
--------------------------------------------------------------------------------
/update.json:
--------------------------------------------------------------------------------
1 | {
2 | "addons": {
3 | "zoterotldr@syt.com": {
4 | "updates": [
5 | {
6 | "version": "1.0.7",
7 | "update_link": "undefined/latest/download/zotero-tldr.xpi",
8 | "applications": {
9 | "zotero": {
10 | "strict_min_version": "6.999"
11 | }
12 | }
13 | }
14 | ]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------