├── pnpm-workspace.yaml ├── src ├── types │ ├── global.d.ts │ ├── index.d.ts │ ├── addon.d.ts │ └── types.d.ts ├── utils │ ├── pluginInfo.ts │ ├── prefs.ts │ ├── hooks.ts │ ├── preferences.tsx │ ├── hooks │ │ ├── urlBinding.ts │ │ ├── keyboard.ts │ │ ├── columns.ts │ │ ├── preferences.ts │ │ ├── menu.ts │ │ ├── prefs.ts │ │ └── dialog.ts │ ├── helpers.ts │ ├── devtools.ts │ ├── prefs.default.ts │ ├── columns.utils.ts │ ├── utils.ts │ └── prisma.ts ├── preferences │ └── main.pn.tsx ├── _injectReact.tsx ├── styles │ └── styles.css ├── preferences.tsx ├── components │ ├── keystrokeInput │ │ ├── keystrokeInput.tsx │ │ └── keystrokeInputUtils.ts │ ├── hooks │ │ └── autocomplete.tsx │ ├── table.tsx │ └── modal.tsx ├── bootstrap.ts ├── index.tsx ├── contextMenu.ts ├── extraColumns.ts ├── views │ ├── commentsView.tsx │ ├── preferencesView.tsx │ ├── statusView.tsx │ └── prismaView.tsx └── addon.tsx ├── postcss.config.mjs ├── assets ├── template.docx ├── icons │ ├── favicon.png │ ├── favicon@0.5x.png │ └── favicon.svg └── locale │ └── en-US │ ├── preferences.ftl │ └── addon.ftl ├── .gitignore ├── typings └── i10n.d.ts ├── tailwind.config.js ├── .vscode └── launch.json ├── update.json ├── update-beta.json ├── manifest.json ├── tsconfig.json ├── package.json ├── README.md └── zotero-plugin.config.ts /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@swc/core' 3 | - core-js 4 | - esbuild 5 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const referenceName: string; 2 | declare const pluginLogPath: string; -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | } 5 | } -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /assets/template.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alima-webdev/zotero-review-assistant/HEAD/assets/template.docx -------------------------------------------------------------------------------- /assets/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alima-webdev/zotero-review-assistant/HEAD/assets/icons/favicon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | .env 4 | .scaffold 5 | package-lock.json 6 | 7 | .DS_Store 8 | 9 | test.* 10 | 11 | raw -------------------------------------------------------------------------------- /assets/icons/favicon@0.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alima-webdev/zotero-review-assistant/HEAD/assets/icons/favicon@0.5x.png -------------------------------------------------------------------------------- /typings/i10n.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by zotero-plugin-scaffold 2 | /* prettier-ignore */ 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | export type FluentMessageId = 6 | ; 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{html,js,jsx,ts,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "command": "pnpm start", 5 | "name": "Run pnpm start", 6 | "request": "launch", 7 | "type": "node-terminal" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /assets/icons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/utils/pluginInfo.ts: -------------------------------------------------------------------------------- 1 | let pluginInfo = { 2 | id: "", 3 | version: "", 4 | rootURI: "", 5 | referenceName: "" 6 | } 7 | 8 | export function getPluginInfo() { 9 | return pluginInfo 10 | } 11 | 12 | export function setPluginInfo(info: {id: string, version: string, rootURI: string, referenceName: string}) { 13 | pluginInfo = info 14 | } -------------------------------------------------------------------------------- /src/utils/prefs.ts: -------------------------------------------------------------------------------- 1 | import { usePrefValue } from "../types/types" 2 | import { getPluginInfo } from "./pluginInfo" 3 | 4 | export function setZoteroPref(name: string, value: usePrefValue) { 5 | Zotero.Prefs.set(`${getPluginInfo().referenceName}.${name}`, value) 6 | } 7 | 8 | export function getZoteroPref(name: string): usePrefValue { 9 | return Zotero.Prefs.get(`${getPluginInfo().referenceName}.${name}`) as usePrefValue 10 | } -------------------------------------------------------------------------------- /assets/locale/en-US/preferences.ftl: -------------------------------------------------------------------------------- 1 | pref-title = Review Assistant 2 | pref-label = Label 3 | pref-name = Name (must be unique) 4 | pref-tag = Tag Name 5 | pref-color = Color 6 | pref-default = Use as the default status for new items 7 | pref-askforreason = Ask to provide a reason when the status changes 8 | pref-keyboardshortcut = Keyboard Shortcut 9 | 10 | pref-enable = 11 | .label = Enable 12 | pref-input = Input 13 | pref-help = { $name } Build { $version } { $time } -------------------------------------------------------------------------------- /update.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "zoteroreviewassistant@alima-webdev.com": { 4 | "updates": [ 5 | { 6 | "version": "2.0.2", 7 | "update_link": "https://github.com/alima-webdev/zotero-review-assistant/releases/download/v2.0.1/zotero-review-assistant.xpi", 8 | "update_hash": "sha256:18501186e8ce8fe4562668a11d89a0e6d326283a10af0f2e78ad79be9abdba7c", 9 | "applications": { 10 | "zotero": { 11 | "strict_min_version": "6.999", 12 | "strict_max_version": "9.999.*" 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | // TODO: 2 | // -- useDialog 3 | // -- useURLBinding 4 | 5 | // Internal Imports 6 | import { useContextMenu } from "./hooks/menu"; 7 | import { useDialog } from "./hooks/dialog"; 8 | import { useExtraColumn } from "./hooks/columns"; 9 | import { useKeyboardShortcut } from "./hooks/keyboard"; 10 | import { usePref } from "./hooks/prefs"; 11 | import { usePreferencesPanel, usePreferencesPanelReact } from "./hooks/preferences"; 12 | 13 | // Devtools 14 | import { log } from "./devtools"; 15 | 16 | export { 17 | useContextMenu, 18 | useDialog, 19 | useExtraColumn, 20 | useKeyboardShortcut, 21 | usePref, 22 | usePreferencesPanel, 23 | usePreferencesPanelReact, 24 | } -------------------------------------------------------------------------------- /update-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "zoteroreviewassistant@alima-webdev.com": { 4 | "updates": [ 5 | { 6 | "version": "2.0.0", 7 | "update_link": "https://github.com/alima-webdev/zotero-review-assistant/releases/download/v2.0.0/zotero-review-assistant.xpi", 8 | "update_hash": "sha512:217e5bfa6fcc0a6e5ae59e1f84aef2d64641fa58144ac645fb7251f4c73bc1760ab30d91fa21313a1124a448d08327ddeba5068e03ee15931772313a984fc856", 9 | "applications": { 10 | "zotero": { 11 | "strict_min_version": "6.999", 12 | "strict_max_version": "7.999.*" 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types/addon.d.ts: -------------------------------------------------------------------------------- 1 | import { KeyCombination } from "./types"; 2 | 3 | // Custom Types 4 | type ArticleStatus = { 5 | tag: string, 6 | label: string, 7 | color: string, 8 | invertTextColor: boolean, 9 | keystroke?: KeyCombination, 10 | } 11 | 12 | // PRISMA Categories 13 | type PRISMACategory = { 14 | name: string, 15 | label: string, 16 | tag: string, 17 | allowCustomReason?: boolean, 18 | } 19 | 20 | // Preferences 21 | type PluginPreferences = { 22 | statusList: ArticleStatus[], 23 | commentsTagPrefix: string, 24 | prismaTagPrefix: string, 25 | prismaCategories: PRISMACategory[], 26 | editStatusShortcut: KeyCombination, 27 | generatePRISMAShortcut: KeyCombination, 28 | } -------------------------------------------------------------------------------- /src/preferences/main.pn.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | // Internal Dependencies 5 | import { setPluginInfo } from '../utils/pluginInfo'; 6 | import PreferencePane from "../views/preferencesView" 7 | 8 | // Types 9 | import { AddonInitProps } from '../types/types'; 10 | 11 | // Devtools 12 | import { log } from '../utils/devtools'; 13 | 14 | // Init function 15 | export function init({ id, version, rootURI, referenceName }: AddonInitProps) { 16 | 17 | // Define the globals.info object 18 | setPluginInfo({ id, version, rootURI, referenceName }) 19 | 20 | const root = createRoot(document.getElementById("app") as HTMLElement) 21 | root.render() 22 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Zotero Review Assistant", 4 | "description": "Zotero Review Assistant", 5 | "homepage_url": "https://github.com/alima-webdev/zotero-review-assistant#readme", 6 | "author": "Alex Lima", 7 | "icons": { 8 | "48": "assets/icons/favicon.svg", 9 | "96": "assets/icons/favicon.svg" 10 | }, 11 | "applications": { 12 | "zotero": { 13 | "id": "zoteroreviewassistant@alima-webdev.com", 14 | "update_url": "https://raw.githubusercontent.com/alima-webdev/zotero-review-assistant/main/update.json", 15 | "strict_min_version": "6.999", 16 | "strict_max_version": "9.999.*" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // tsconfig.json 2 | { 3 | "sourceMap": true, 4 | "include": [ 5 | "./src/**/*", 6 | "./node_modules/zotero-types", 7 | "./src/types/**/*", 8 | ], 9 | "compilerOptions": { 10 | "jsx": "react-jsx", // Or "preserve" if you want to use a different JSX factory 11 | "module": "esnext", // Or "commonjs" depending on your project 12 | "target": "es5", // Or a higher target if your environment supports it 13 | "esModuleInterop": true, 14 | "strict": true, // Recommended for better type safety 15 | "moduleResolution": "node", // Or "classic" depending on your project 16 | "allowSyntheticDefaultImports": true, // For compatibility with some libraries 17 | // "typeRoots": ["./src/types"] 18 | } 19 | } -------------------------------------------------------------------------------- /src/_injectReact.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { createRoot } from 'react-dom/client'; 3 | import { ReactNode } from 'react'; 4 | 5 | // Internal Dependencies 6 | import { setPluginInfo } from './utils/pluginInfo'; 7 | import PreferencePane from "./views/preferencesView" 8 | 9 | // Types 10 | import { AddonInitProps } from './types/types'; 11 | 12 | // Devtools 13 | import { log } from './utils/devtools'; 14 | 15 | // Types 16 | interface injectReactProps extends AddonInitProps { 17 | component: ReactNode 18 | } 19 | 20 | // Inject function 21 | export function injectReact({ component, id, version, rootURI, referenceName }: injectReactProps) { 22 | 23 | // Define the globals.info object 24 | setPluginInfo({ id, version, rootURI, referenceName }) 25 | 26 | const root = createRoot(document.getElementById("app") as HTMLElement) 27 | root.render(component) 28 | } -------------------------------------------------------------------------------- /assets/locale/en-US/addon.ftl: -------------------------------------------------------------------------------- 1 | startup-begin = Review Assistant is loading 2 | startup-finish = Review Assistant is ready 3 | 4 | menuitem-label = Review Assistant: Helper Examples 5 | menupopup-label = Review Assistant: Menupopup 6 | menuitem-submenulabel = Review Assistant 7 | menuitem-filemenulabel = Review Assistant: File Menuitem 8 | 9 | prefs-title = Review Assistant 10 | prefs-table-id = Id 11 | prefs-table-name = Name 12 | prefs-table-tag = Tag 13 | prefs-table-label = Label 14 | prefs-table-color = Color 15 | prefs-table-askforreason = Ask for a Reason 16 | prefs-table-default = Default Status 17 | prefs-table-keyboardshortcut = Keyboard Shortcut 18 | 19 | context-menu-status = Review Assistant 20 | 21 | reason-dialog-title = Reason 22 | reason-dialog-text = Provide a reason for the selected status (optional) 23 | reason-column-header = Reason 24 | 25 | status-column-header = Status 26 | status-blank-label = Not Reviewed 27 | status-total-label = Total 28 | 29 | report-dialog-title = Generate a PRISMA Diagram 30 | report-dialog-table-status = Status 31 | report-dialog-table-count = Number of Articles 32 | 33 | report-reason-default-label = No reason provided 34 | -------------------------------------------------------------------------------- /src/utils/preferences.tsx: -------------------------------------------------------------------------------- 1 | // import { log } from "./devtools"; 2 | // import globals from "./globals"; 3 | // import { signal, effect } from "@preact/signals"; 4 | 5 | 6 | // // type Status = { 7 | // // tag: string, 8 | // // label: string, 9 | // // color: string, 10 | // // keystroke?: string, 11 | // // } 12 | 13 | // // [ 14 | // // { tag: "Status: Test", label: "Test", color: "#ff0000", keystroke: "" } 15 | // // ] 16 | 17 | 18 | // // let preferences = new class { 19 | // // statuses = signal([]) 20 | // // init() { 21 | // // // Check if the preferences exist 22 | // // if(Zotero.Prefs.get(`${globals.info.referenceName}.statuses`) === undefined) { 23 | // // Zotero.Prefs.set(`${globals.info.referenceName}.statuses`, JSON.stringify([])); 24 | // // } 25 | 26 | // // log(`${globals.info.referenceName}`) 27 | // // log(Zotero.Prefs.get(`${globals.info.referenceName}.statuses`)) 28 | 29 | // // // Load the preferences 30 | // // this.statuses.value = JSON.parse(Zotero.Prefs.get(`${globals.info.referenceName}.statuses`) as string) as Status[] 31 | 32 | // // // Add effect signals 33 | // // effect(() => { 34 | // // Zotero.Prefs.set(`${globals.info.referenceName}.statuses`, JSON.stringify(this.statuses)); 35 | // // }) 36 | // // } 37 | // // } 38 | 39 | // // export default preferences -------------------------------------------------------------------------------- /src/styles/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* Custom styles */ 4 | .rt-badge { 5 | /* @apply inline-flex; */ 6 | @apply inline-flex; 7 | @apply mx-1; 8 | @apply px-2 py-1; 9 | @apply rounded-md; 10 | @apply bg-gray-200; 11 | 12 | @apply text-slate-950; 13 | @apply text-sm; 14 | } 15 | 16 | .invert-text { 17 | @apply text-white; 18 | } 19 | 20 | /* Preferences */ 21 | /* .preferences { 22 | } */ 23 | 24 | /* Buttons */ 25 | .btn { 26 | @apply max-h-none; 27 | @apply flex flex-row; 28 | @apply px-4 py-2 rounded-sm; 29 | @apply text-sm; 30 | 31 | &.btn-clear { 32 | @apply text-blue-600 dark:text-blue-400; 33 | @apply hover:text-blue-500 dark:hover:text-blue-500; 34 | } 35 | 36 | &.btn-primary { 37 | @apply !text-slate-50; 38 | @apply bg-blue-500 hover:bg-blue-400; 39 | @apply dark:bg-blue-600 dark:hover:bg-blue-500; 40 | @apply shadow-sm; 41 | } 42 | 43 | &.btn-default { 44 | @apply !text-black; 45 | @apply bg-gray-200 hover:bg-gray-300; 46 | @apply shadow-sm; 47 | } 48 | } 49 | 50 | /* Form */ 51 | .input { 52 | @apply p-2 m-0; 53 | @apply border border-slate-200 rounded-sm; 54 | @apply text-sm; 55 | @apply text-slate-950 bg-slate-100; 56 | @apply outline-none; 57 | @apply focus:border-blue-400; 58 | } 59 | 60 | .select { 61 | @apply appearance-none bg-transparent z-10; 62 | } -------------------------------------------------------------------------------- /src/utils/hooks/urlBinding.ts: -------------------------------------------------------------------------------- 1 | // URL Binding 2 | import { log } from "../devtools"; 3 | 4 | // TODO: Implement 5 | export function useURLBinding() { 6 | 7 | // Zotero.URIHandler.register({ 8 | // uri: "zotero://blah", 9 | // action: async (uri: { spec: string }) => { 10 | // log("URI", uri) 11 | // } 12 | // }) 13 | let ioExtension = { 14 | // loadAsChrome: false, 15 | // noContent: true, 16 | doAction: async (uri: { spec: string }) => { 17 | log("URI", uri) 18 | 19 | // Check if userPass is supported before using it 20 | try { 21 | if ("userPass" in uri) { 22 | log("userPass:", uri.userPass); // Avoid accessing if unsupported 23 | } 24 | } catch (e) { 25 | log("userPass not supported for this URI:", e); 26 | } 27 | }, 28 | newChannel: (uri: any, loadInfo: any) => { 29 | log("URI 2", uri) 30 | ioExtension.doAction(uri) 31 | // log("URI 2", uri) 32 | 33 | // return new AsyncChannel(uri, loadInfo, function* () { 34 | // }) 35 | // return ioExtension.doAction(uri) 36 | return null; // Ensure the method returns a value 37 | }, 38 | } 39 | const { Services } = ChromeUtils.importESModule("resource://gre/modules/Services.jsm"); 40 | // @ts-ignore 41 | Services.io.getProtocolHandler("zotero").wrappedJSObject._extensions["zotero://blah"] = ioExtension; 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/hooks/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { KeyboardShortcutOptions, KeyCombination } from "../../types/types"; 3 | 4 | // Keyboard Shortcuts 5 | const keyboardShortcutManager = new class { 6 | private shortcuts: KeyboardShortcutOptions[] = []; 7 | public initialized: boolean = false 8 | 9 | private handleKeyDown(event: KeyboardEvent) { 10 | for (const shortcut of this.shortcuts) { 11 | 12 | if (event.code === shortcut.keystroke.code && 13 | event.ctrlKey === shortcut.keystroke.ctrlKey && 14 | event.shiftKey === shortcut.keystroke.shiftKey && 15 | event.altKey === shortcut.keystroke.altKey && 16 | event.metaKey === shortcut.keystroke.metaKey 17 | ) { 18 | shortcut.callback() 19 | event.preventDefault() 20 | event.stopPropagation() 21 | } 22 | } 23 | } 24 | 25 | public init() { 26 | if (this.initialized) return; 27 | document.documentElement.addEventListener("keydown", this.handleKeyDown.bind(this)) 28 | this.initialized = true 29 | } 30 | 31 | public addShortcut(options: KeyboardShortcutOptions) { 32 | this.shortcuts.push(options); 33 | } 34 | } 35 | 36 | export function useKeyboardShortcut(keystroke: KeyCombination, callback: () => void, deps: any[] = []) { 37 | useEffect(() => { 38 | if (!keyboardShortcutManager.initialized) keyboardShortcutManager.init() 39 | keyboardShortcutManager.addShortcut({ keystroke, callback: () => { callback(deps) } }); 40 | }); 41 | } -------------------------------------------------------------------------------- /src/utils/hooks/columns.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { getPluginInfo } from "../pluginInfo"; 3 | import { log } from "../devtools"; 4 | // import { extraColumns } from "../columns.utils"; 5 | 6 | // Extra Columns 7 | export async function useExtraColumn(options: _ZoteroTypes.ItemTreeManager.ItemTreeColumnOptions) { 8 | // log("useExtraColumn", options) 9 | // Ensure the column options includes dataKey 10 | // if (extraColumns.includes(options.dataKey)) return 11 | 12 | // Column Options 13 | const columnOptions = { 14 | pluginID: getPluginInfo().id, 15 | enabledTreeIDs: ['main'], 16 | ...options, 17 | } 18 | 19 | // Register the columm 20 | useEffect(() => { 21 | const registerColumn = async () => { 22 | const registerColumnFn = 23 | Zotero.ItemTreeManager.registerColumn || 24 | Zotero.ItemTreeManager.registerColumns; 25 | const dataKey = await registerColumnFn.apply(Zotero.ItemTreeManager, [columnOptions]) 26 | // const dataKey = await Zotero.ItemTreeManager.registerColumn(columnOptions) 27 | if (!dataKey) return; 28 | 29 | await Zotero.ItemTreeManager.refreshColumns() 30 | // extraColumns.push(dataKey) 31 | } 32 | registerColumn() 33 | return () => { 34 | const unregisterColumn = async () => { 35 | await Zotero.ItemTreeManager.unregisterColumn(columnOptions.dataKey) 36 | await Zotero.ItemTreeManager.refreshColumns() 37 | } 38 | unregisterColumn() 39 | } 40 | }, []) 41 | } -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | // Helper functions 2 | // import { prismaSections } from "../lib/global"; 3 | // import { log } from "./devtools"; 4 | 5 | // File picker 6 | const { FilePicker } = ChromeUtils.importESModule( 7 | "chrome://zotero/content/modules/filePicker.mjs", 8 | ); 9 | 10 | export function generateMenuIcon(color: string) { 11 | return ( 12 | "data:image/svg+xml;base64," + 13 | Zotero.getMainWindow().btoa( 14 | ``, 15 | ) 16 | ); 17 | } 18 | 19 | export function loadLocalFile(src: string) { 20 | return Zotero.File.getContentsFromURL(src); 21 | } 22 | 23 | // Count the number of items with a certain tag given an array of items 24 | export function countItemsWithTag(tag: string, items: Zotero.Item[]) { 25 | let count = 0; 26 | 27 | for (const item of items) { 28 | if (item.hasTag(tag)) { 29 | count++; 30 | } 31 | } 32 | 33 | return count; 34 | } 35 | 36 | export async function showFilePicker( 37 | mime = "*", 38 | filter: number = FilePicker.filterAll, 39 | name: string = "", 40 | ): Promise { 41 | const ext = Zotero.MIME.getPrimaryExtension(mime, ""); 42 | const fp = new FilePicker(); 43 | fp.init(Zotero.getMainWindow(), name, fp.modeSave); 44 | fp.appendFilters(filter); 45 | fp.defaultString = name + "." + ext; 46 | const rv = await fp.show(); 47 | 48 | let outputPath; 49 | if (rv === fp.returnOK || rv === fp.returnReplace) { 50 | outputPath = fp.file; 51 | } 52 | return outputPath; 53 | } 54 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | // Addon.ts 2 | export type AddonInitProps = { 3 | id: string, 4 | version: string, 5 | rootURI: string, 6 | referenceName: string 7 | } 8 | 9 | // Hooks.ts 10 | type usePreferencesProps = { 11 | id: string, 12 | label: string, 13 | type?: string, 14 | image?: string, 15 | src: string, 16 | scripts?: string[], 17 | stylesheets?: string[] 18 | } 19 | 20 | type usePreferencesPanelReactProps = { 21 | id: string, 22 | label: string, 23 | type?: string, 24 | image?: string, 25 | additionalScripts?: string[], 26 | stylesheets?: string[] 27 | } 28 | 29 | type KeyCombination = { 30 | ctrlKey: boolean, 31 | altKey: boolean, 32 | shiftKey: boolean, 33 | metaKey: boolean, 34 | code: string, 35 | } 36 | type KeyboardShortcutOptions = { 37 | keystroke: KeyCombination; 38 | callback: () => void; 39 | } 40 | 41 | type MenuItem = { 42 | type: "menuseparator" | "menuitem" | "menu", 43 | label?: string, 44 | image?: string, 45 | action?: (event: CommandEvent) => void 46 | children?: MenuItem[] 47 | } 48 | 49 | // Pref 50 | type usePrefValue = string | number | boolean 51 | type usePrefOptions = { 52 | parseJSON?: boolean, 53 | observe?: boolean, 54 | } 55 | type usePrefStateValue = usePrefValue | Object | any[] 56 | type usePrefStateFunction = (value: any) => void 57 | type usePrefState = [usePrefStateValue, usePrefStateFunction] 58 | 59 | // Components 60 | type TableColumn = { 61 | name: string, 62 | label: string, 63 | labelFn?: (cell: any) => string 64 | } 65 | type TableProps = { 66 | columns: TableColumn[] 67 | data: any[], 68 | onDoubleClick?: (item: any, index: number) => void 69 | } -------------------------------------------------------------------------------- /src/preferences.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | // Internal Dependencies 5 | import { getPluginInfo, setPluginInfo } from './utils/pluginInfo'; 6 | import PreferencePane from "./views/preferencesView" 7 | 8 | // Types 9 | import { AddonInitProps } from './types/types'; 10 | 11 | // Devtools 12 | import { log, wait } from './utils/devtools'; 13 | 14 | 15 | 16 | import pkg from "../package.json" assert { type: "json" }; 17 | // Init function 18 | // export function init({ id, version, rootURI, referenceName }: AddonInitProps) { 19 | 20 | // Define the globals.info object 21 | // setPluginInfo({ id, version, rootURI, referenceName }) 22 | 23 | function getPreferencePane(id: string) { 24 | return Zotero.PreferencePanes.pluginPanes.find(p => p.pluginID === id) 25 | } 26 | const pluginId = pkg["zotero-react"].plugin.id 27 | const prefPane = getPreferencePane(pluginId) 28 | 29 | setPluginInfo({ 30 | id: pluginId, 31 | version: pkg.version, 32 | rootURI: `chrome://${pluginId}/content/`, 33 | referenceName: pkg["zotero-react"].plugin.namespace, 34 | }) 35 | 36 | async function initPreferencesPane() { 37 | await wait(1000) 38 | if (document.querySelector(`[data-plugin-id="${pluginId}"]`) === null) { 39 | initPreferencesPane() 40 | return; 41 | } 42 | 43 | document.querySelector("window").addEventListener("beforeunload", () => { 44 | log("Unloading Preferences Pane") 45 | }) 46 | 47 | const container = document.querySelector(`[data-plugin-id="${pluginId}"] #app`) as HTMLElement 48 | log("###############################################################") 49 | 50 | log("index.tsx: Initializing Preferences Pane") 51 | log(container) 52 | 53 | const root = createRoot(container) 54 | root.render() 55 | } 56 | initPreferencesPane() -------------------------------------------------------------------------------- /src/components/keystrokeInput/keystrokeInput.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState, forwardRef } from 'react'; 2 | import { isMainKeyValid, Keystroke } from './keystrokeInputUtils'; 3 | 4 | const KeystrokeInput = forwardRef(({setValue = () => {}, ...props}, ref) => { 5 | 6 | // States 7 | const [keystrokeValue, setKeystrokeValue] = useState(Keystroke.fromString("")); 8 | 9 | // Events 10 | const onKeyDownHandler = (event: React.KeyboardEvent) => { 11 | console.log("onKeyDown") 12 | 13 | // Ignore repeat 14 | if (event.repeat) return; 15 | 16 | // Get the keystroke and construct the class 17 | const code = isMainKeyValid(event.code) 18 | ? event.code.replace(/Key|Digit/g, '') 19 | : ''; 20 | const isAlt = event.altKey; 21 | const isCtrl = event.ctrlKey; 22 | const isMeta = event.metaKey; 23 | const isShift = event.shiftKey; 24 | 25 | const keystroke = new Keystroke(); 26 | keystroke.modifiers = { 27 | alt: isAlt, 28 | ctrl: isCtrl, 29 | meta: isMeta, 30 | shift: isShift, 31 | }; 32 | keystroke.code = code; 33 | 34 | // Set the element value 35 | setKeystrokeValue(keystroke) 36 | 37 | // Prevent the default behavior 38 | // event.preventDefault() 39 | // return false 40 | }; 41 | 42 | const inputValue = (keystrokeValue ? keystrokeValue.toString() : "") 43 | 44 | const onChangeHandler = (event: ChangeEvent) => { 45 | 46 | // React Hook Form's setValue 47 | if (setValue && props.name) setValue(props.name, keystrokeValue) 48 | 49 | // Trigger onChange 50 | if (props.onChange) props.onChange(event) 51 | 52 | } 53 | return ( 54 | 55 |
{keystrokeValue.toJSON()}
56 | 62 |
63 | ); 64 | }); 65 | 66 | export default KeystrokeInput; 67 | -------------------------------------------------------------------------------- /src/utils/devtools.ts: -------------------------------------------------------------------------------- 1 | // Log to file 2 | export async function log(...message: any[]) { 3 | // Log on the console 4 | if(Zotero.getMainWindow().console) Zotero.getMainWindow().console.log(...message) 5 | else Zotero.log(JSON.stringify(message)) 6 | 7 | // Write it to file 8 | if (pluginLogPath && pluginLogPath != "") { 9 | const outputFile = Zotero.File.pathToFile(pluginLogPath || ""); 10 | const now = new Date(); 11 | const timestamp = `${(now.getMonth() + 1).toString().padStart(2, '0')}/` + 12 | `${now.getDate().toString().padStart(2, '0')}/` + 13 | `${now.getFullYear()} ` + 14 | `${now.getHours().toString().padStart(2, '0')}:` + 15 | `${now.getMinutes().toString().padStart(2, '0')}:` + 16 | `${now.getSeconds().toString().padStart(2, '0')}`; 17 | const existing = await Zotero.File.getContentsAsync(outputFile).catch(() => ""); 18 | const formattedMessage = message.map(m => { 19 | try { 20 | return typeof m === "object" ? JSON.stringify(m) : String(m); 21 | } catch { 22 | return String(m); 23 | } 24 | }).join(" "); 25 | const newContent = `${existing}${timestamp} - ${formattedMessage}\n`; 26 | await Zotero.File.putContentsAsync(outputFile, newContent); 27 | } 28 | } 29 | 30 | export function wait(milliseconds: number) { 31 | return new Promise((resolve) => { 32 | setTimeout(resolve, milliseconds); 33 | }); 34 | } 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | // export function log(...args: any[]) { 49 | // window.console.log(processArgs(args).join(", ")) 50 | // } 51 | // export function warn(...args: any[]) { 52 | // window.console.warn(processArgs(args).join(", ")) 53 | // } 54 | // export function error(...args: any[]) { 55 | // window.console.error(processArgs(args).join(", ")) 56 | // } 57 | 58 | // function processArgs(args: any[]) { 59 | // return args.map(arg => { 60 | // if (typeof arg === "object") { 61 | // return JSON.stringify(arg) 62 | // } else { 63 | // return arg 64 | // } 65 | // }) 66 | // } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zotero-review-assistant", 3 | "version": "2.0.2", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/alima-webdev/zotero-review-assistant.git" 7 | }, 8 | "scripts": { 9 | "start": "zotero-plugin serve", 10 | "build": "zotero-plugin build", 11 | "release": "zotero-plugin release" 12 | }, 13 | "devDependencies": { 14 | "@tailwindcss/postcss": "^4.1.17", 15 | "@types/react": "^19.2.4", 16 | "@types/react-dom": "^19.2.3", 17 | "autoprefixer": "^10.4.22", 18 | "esbuild-plugin-tailwindcss": "^2.1.0", 19 | "esbuild-style-plugin": "^1.6.3", 20 | "postcss": "^8.5.6", 21 | "tailwindcss": "^4.1.17", 22 | "zotero-plugin-scaffold": "^0.6.1", 23 | "zotero-types": "4.0.0-beta.10" 24 | }, 25 | "dependencies": { 26 | "@tanstack/react-table": "^8.21.3", 27 | "autocompleter": "^9.3.2", 28 | "docxtemplater": "^3.67.4", 29 | "esbuild-sass-plugin": "^3.3.1", 30 | "keystroke-input": "file:/Users/alexlima/Documents/Development/React Components/Keystroke Input/", 31 | "lucide-react": "^0.510.0", 32 | "pizzip": "^3.2.0", 33 | "react": "^19.2.0", 34 | "react-dom": "^19.2.0", 35 | "react-hook-form": "^7.66.0", 36 | "typescript": "^5.9.3", 37 | "zotero-plugin-toolkit": "^5.0.1" 38 | }, 39 | "zotero-react": { 40 | "entryPoints": [ 41 | "src/index.tsx", 42 | "src/preferences.tsx", 43 | "src/styles/styles.css" 44 | ], 45 | "plugin": { 46 | "name": "Zotero Review Assistant", 47 | "id": "zoteroreviewassistant@alima-webdev.com", 48 | "namespace": "Zoteroreviewassistant", 49 | "releasePage": "https://github.com/alima-webdev/zotero-review-assistant/releases", 50 | "updateURL": "https://raw.githubusercontent.com/alima-webdev/zotero-review-assistant/main/update.json" 51 | } 52 | }, 53 | "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c" 54 | } 55 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | // Devtools 2 | import { log } from "./utils/devtools"; 3 | import { setPluginInfo } from "./utils/pluginInfo"; 4 | 5 | // import from "../package.json" 6 | 7 | // Log when Zotero initializes the plugin 8 | // log("ZOTERO INIT") 9 | 10 | // Plugin Options 11 | // Use referenceName for the reference name to be used 12 | 13 | // Consts 14 | const mainWindow = Zotero.getMainWindow() 15 | 16 | // Install 17 | function install(props) { 18 | log("Install", props) 19 | // registerPrefs() 20 | // startup() 21 | } 22 | 23 | // Startup 24 | type StartupProps = { 25 | id: string, 26 | version: string, 27 | rootURI: string, 28 | } 29 | async function startup({ id, version, rootURI }: StartupProps) { 30 | log("Startup", { id, version, rootURI, referenceName }) 31 | 32 | // Set the plugin info for internal use 33 | setPluginInfo({ id, version, rootURI, referenceName }) 34 | 35 | // Wait for Zotero to be ready 36 | await Promise.all([ 37 | Zotero.initializationPromise, 38 | Zotero.unlockPromise, 39 | Zotero.uiReadyPromise, 40 | ]); 41 | 42 | // String 'rootURI' introduced in Zotero 7 43 | if (!rootURI) { 44 | // @ts-ignore legacy support for Zotero 6 45 | rootURI = resourceURI.spec; 46 | } 47 | 48 | // UI 49 | // JS 50 | await Services.scriptloader.loadSubScript(rootURI + "/index.js", Zotero.getMainWindow()); 51 | 52 | // Init 53 | Zotero.getMainWindow()[referenceName].init({ id, version, rootURI, referenceName }); 54 | } 55 | 56 | function onMainWindowLoad(props) { 57 | log("onMainWindowLoad", props) 58 | if (mainWindow[referenceName].onMainWindowLoad) { 59 | mainWindow[referenceName].onMainWindowLoad({ window }); 60 | } 61 | } 62 | 63 | function onMainWindowUnload(props) { 64 | log("onMainWindowUnlad", props) 65 | if (mainWindow[referenceName].onMainWindowUnload) { 66 | mainWindow[referenceName].onMainWindowUnload({ window }); 67 | } 68 | } 69 | 70 | function shutdown(props) { 71 | log("Shutdown", props) 72 | if (Zotero.getMainWindow()[referenceName].shutdown) { 73 | Zotero.getMainWindow()[referenceName].shutdown(props); 74 | // mainWindow[referenceName] = undefined 75 | } 76 | } 77 | 78 | function uninstall(props) { 79 | log("Uninstall", props) 80 | if (mainWindow[referenceName].uninstall) { 81 | mainWindow[referenceName].uninstall(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/prefs.default.ts: -------------------------------------------------------------------------------- 1 | // Internal Dependencies 2 | import { getKeyCombination } from "./utils" 3 | 4 | // Types 5 | import { PluginPreferences } from "../types/addon" 6 | 7 | // Default preferences 8 | const defaultPreferences: PluginPreferences = { 9 | // Status list 10 | statusList: [ 11 | { label: "Included", tag: "#Status: Included", color: "#60a5fa", invertTextColor: false, keystroke: getKeyCombination("Alt I") }, 12 | { label: "Excluded", tag: "#Status: Excluded", color: "#f87171", invertTextColor: true, keystroke: getKeyCombination("Alt X") }, 13 | { label: "Pending", tag: "#Status: Pending", color: "#d4d4d4", invertTextColor: false, keystroke: getKeyCombination("Alt P") }, 14 | { label: "Unsure", tag: "#Status: Unsure", color: "#facc15", invertTextColor: false, keystroke: getKeyCombination("Alt U") }, 15 | { label: "Clear", tag: "", color: "transparent", invertTextColor: false, keystroke: getKeyCombination("Alt Minus") }, 16 | ], 17 | // Comments tag prefix 18 | commentsTagPrefix: "#Status: Comments: ", 19 | 20 | // PRISMA tag prefix 21 | prismaTagPrefix: "#Status: PRISMA: ", 22 | // PRISMA categories 23 | prismaCategories: [ 24 | { label: "Identification: Duplicated", name: "identification:duplicated", tag: "#Status: PRISMA: Identification: Duplicated" }, 25 | { label: "Identification: Automation", name: "identification:automation", tag: "#Status: PRISMA: Identification: Automation" }, 26 | { label: "Identification: Other", name: "identification:other", tag: "#Status: PRISMA: Identification: Other" }, 27 | { label: "Screening: Excluded", name: "screening:screen:excluded", tag: "#Status: PRISMA: Screening: Excluded Before Screening" }, 28 | { label: "Screening: Not Retrieved", name: "screening:retrieval:excluded", tag: "#Status: PRISMA: Retrieval: Not Retrieved" }, 29 | { label: "Screening: Not Eligible", name: "screening:eligibility:excluded", tag: "#Status: PRISMA: Eligibility: Not Eligible", allowCustomReason: true }, 30 | { label: "Included: Study", name: "included:studies", tag: "#Status: PRISMA: Included: Study" }, 31 | { label: "Included: Report", name: "included:reports", tag: "#Status: PRISMA: Included: Report" }, 32 | ], 33 | 34 | // Keyboard Shortcuts 35 | editStatusShortcut: getKeyCombination("Alt S"), 36 | generatePRISMAShortcut: getKeyCombination("Alt D"), 37 | } 38 | export default defaultPreferences 39 | -------------------------------------------------------------------------------- /src/components/hooks/autocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEventHandler, useEffect, useState } from "react"; 2 | import { log } from "../../utils/devtools"; 3 | 4 | export function useAutocomplete(suggestionList: string[], handleSelection: (suggestion: string) => void) { 5 | // States 6 | const [selectedIndex, setSelectedIndex] = useState(-1) 7 | const [suggestions, setSuggestions] = useState([...suggestionList]) 8 | const [isOpen, setIsOpen] = useState(false) 9 | const [previousValue, setPreviousValue] = useState("") 10 | 11 | useEffect(() => { 12 | setSuggestions([...suggestionList]); 13 | }, [suggestionList]); 14 | 15 | // -- KeyDown 16 | const onKeyDown: KeyboardEventHandler = (event: React.KeyboardEvent) => { 17 | 18 | switch (event.key) { 19 | case "ArrowUp": 20 | if (!isOpen) return; 21 | setSelectedIndex(Math.max(selectedIndex - 1, -1)) 22 | event.preventDefault() 23 | break; 24 | 25 | case "ArrowDown": 26 | setSelectedIndex(Math.min(selectedIndex + 1, suggestions.length - 1)) 27 | if (!isOpen) setIsOpen(true) 28 | event.preventDefault() 29 | break; 30 | 31 | case "Enter": 32 | if (!isOpen) return; 33 | handleSelection(suggestionList[selectedIndex]) 34 | setIsOpen(false) 35 | event.preventDefault() 36 | break; 37 | 38 | default: 39 | break; 40 | } 41 | } 42 | 43 | // -- KeyUp (fetch suggestions) 44 | const onKeyUp: KeyboardEventHandler = (event: React.KeyboardEvent) => { 45 | log("onKeyUp") 46 | const value = (event.currentTarget as HTMLInputElement).value.toLowerCase(); 47 | 48 | if (value !== previousValue) { 49 | if (value === "") { 50 | setSuggestions([...suggestionList]); 51 | } else { 52 | const newSuggestions = suggestionList.filter((comment) => 53 | comment.toLowerCase().startsWith(value) 54 | ); 55 | setSuggestions(newSuggestions); 56 | } 57 | setSelectedIndex(-1); 58 | setPreviousValue(value); 59 | } 60 | } 61 | 62 | return { onKeyUp, onKeyDown, suggestions, selectedIndex, isOpen, setIsOpen } 63 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { createRoot } from 'react-dom/client'; 3 | // Internal Dependencies 4 | import { getPluginInfo, setPluginInfo } from './utils/pluginInfo'; 5 | 6 | // Main React Component 7 | import Addon from "./addon" 8 | 9 | // Types 10 | import { AddonInitProps } from "./types/types" 11 | import { unregisterAllColumns } from './utils/columns.utils'; 12 | import { log } from './utils/devtools'; 13 | import { registerExtraColumns } from './extraColumns'; 14 | import { registerContextMenu, registerReviewContextMenu } from './contextMenu'; 15 | import { usePreferencesPanelReact } from './utils/hooks'; 16 | 17 | // This is the entry point for the Zotero add-on 18 | export async function init({ id, version, rootURI, referenceName }: AddonInitProps) { 19 | 20 | // Define the globals.info object 21 | setPluginInfo({ id, version, rootURI, referenceName }) 22 | 23 | // Register Extra Columns 24 | // await registerExtraColumns(id) 25 | // window.colFn = registerExtraColumns 26 | 27 | // log(registerExtraColumns) 28 | // log(id) 29 | 30 | // // Register Context Menu 31 | // await registerReviewContextMenu(id, version, rootURI, referenceName) 32 | 33 | // // Preferences 34 | // await usePreferencesPanelReact({ 35 | // id: "main", // Has to match the file name (e.g., .src/preferences/main.pn.tsx => id: "main") 36 | // label: "Zotero Review Assistant", 37 | // image: "assets/icons/favicon.svg", 38 | // stylesheets: ["styles/styles.css"] 39 | // }) 40 | 41 | // // Inject the CSS styles 42 | // const styles = document.createElement("link") 43 | // styles.href = `${rootURI}/styles/styles.css` 44 | // styles.rel = "stylesheet" 45 | // document.documentElement.appendChild(styles) 46 | 47 | // Create the react plugin element and render 48 | const appElement = document.createElement("div") 49 | appElement.id = referenceName + "-app" 50 | document.documentElement.appendChild(appElement) 51 | const root = createRoot(appElement); 52 | root.render() 53 | } 54 | 55 | export async function shutdown() { 56 | log("index.ts: shutdown") 57 | await unregisterAllColumns() 58 | const app = document.getElementById(getPluginInfo().referenceName + "-app") 59 | app?.remove() 60 | } 61 | 62 | export async function onMainWindowUnload({ window }: { window: Window }) { 63 | await unregisterAllColumns() 64 | const app = document.getElementById(getPluginInfo().referenceName + "-app") 65 | app?.remove() 66 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zotero Review Assistant Plugin 2 | 3 | [![zotero target version](https://img.shields.io/badge/Zotero-7-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org) 4 | [![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template) 5 | 6 | ### IMPORTANT: This plugin is only compatible with Zotero 7 7 | 8 | https://github.com/alima-webdev/zotero-review-assistant/assets/65861197/d350ec1c-214d-4fc3-9f4b-bd23c549a4fd 9 | 10 | ## Overview 11 | 12 | This plugin for Zotero aims to streamline the process of organizing articles for review research. It provides users with the ability to assign statuses such as "Included," "Excluded," or "Unsure" to articles, along with reasons for their current status. Additionally, it offers an automated feature to generate PRISMA flow diagrams, simplifying the reporting process. 13 | 14 | ## Features 15 | 16 | - **Status Assignment**: Users can easily assign a status to each article in their Zotero library, allowing for efficient organization and tracking of inclusion/exclusion decisions. 17 | - **Reason Documentation**: Alongside status assignment, users can provide detailed reasons for each status, facilitating transparency and reproducibility in the review process. 18 | - **PRISMA Flow Diagram Generation**: The plugin automates the creation of PRISMA flow diagrams, saving time and effort in reporting the flow of literature through the review process. 19 | - **Customization**: The plugin is customizable to fit various review methodologies and reporting standards, ensuring flexibility for different research projects. 20 | - **Intuitive Interface**: Designed with user experience in mind, the plugin offers a user-friendly interface that integrates seamlessly with Zotero's existing functionalities. 21 | 22 | ## Installation 23 | 24 | 1. **Download the Plugin** 25 | - Go to the Releases section and download the latests .xpi file. 26 | 27 | 2. **Install in Zotero** 28 | - Open Zotero 29 | - Navigate to **Tools > Plugins** 30 | - Click the gear icon in the top-right corner of the window. 31 | - Select **Install Plugin From File...** 32 | - Choose the downloaded .xpi file 33 | 34 | ## Contribution 35 | 36 | Contributions to the plugin are welcome! If you have ideas for improvements or would like to report a bug, please open an issue on GitHub. 37 | 38 | ## License 39 | 40 | This plugin is released under the GNU GENERAL PUBLIC license. See the LICENSE file for more details. -------------------------------------------------------------------------------- /zotero-plugin.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "zotero-plugin-scaffold"; 2 | 3 | import pkg from "./package.json" 4 | import postCssPlugin from 'esbuild-style-plugin' 5 | 6 | // Plugin info 7 | let pluginInfo = pkg["zotero-react"].plugin 8 | 9 | export default defineConfig({ 10 | // Plugin info 11 | ...pluginInfo, 12 | 13 | // Build 14 | build: { 15 | // Manifest options 16 | assets: ["assets", "manifest.json"], 17 | makeManifest: { 18 | enable: true, 19 | }, 20 | // assets: ["assets", "bootstrap.js"], 21 | 22 | // Prefs 23 | prefs: { 24 | prefixPrefKeys: true, 25 | prefix: `extensions.${pkg["zotero-react"].plugin.namespace}`, 26 | dts: "typings/prefs.d.ts" 27 | }, 28 | 29 | // ESBuild 30 | esbuildOptions: [ 31 | { 32 | // Entry points 33 | entryPoints: pkg["zotero-react"].entryPoints, 34 | bundle: true, 35 | target: "firefox115", 36 | outdir: ".scaffold/build/addon", 37 | format: "iife", 38 | treeShaking: false, 39 | sourcemap: true, 40 | globalName: pluginInfo.namespace, 41 | 42 | // Global variables 43 | define: { 44 | referenceName: `"${pluginInfo.namespace}"`, 45 | pluginLogPath: `"${process.env.ZOTERO_PLUGIN_LOG_PATH}"`, 46 | }, 47 | 48 | // Post CSS + Tailwind 49 | plugins: [ 50 | postCssPlugin({ 51 | postcss: { 52 | plugins: [require('@tailwindcss/postcss'), require('autoprefixer')], 53 | }, 54 | }), 55 | ], 56 | }, 57 | 58 | // Bootstrap 59 | { 60 | entryPoints: ["src/bootstrap.ts"], 61 | bundle: true, 62 | target: "firefox115", 63 | format: "cjs", 64 | treeShaking: false, 65 | sourcemap: true, 66 | outdir: ".scaffold/build/addon", 67 | define: { 68 | referenceName: `"${pluginInfo.namespace}"`, 69 | pluginLogPath: `"${process.env.ZOTERO_PLUGIN_LOG_PATH}"`, 70 | }, 71 | }, 72 | ], 73 | }, 74 | 75 | // Dev server 76 | server: { 77 | asProxy: true, 78 | devtools: true, 79 | startArgs: [], 80 | }, 81 | 82 | }); -------------------------------------------------------------------------------- /src/utils/hooks/preferences.ts: -------------------------------------------------------------------------------- 1 | import { usePreferencesPanelReactProps, usePreferencesProps } from "../../types/types"; 2 | import { log } from "../devtools"; 3 | import { getPluginInfo } from "../pluginInfo"; 4 | 5 | // Preference Panels 6 | export const preferencesPanels: (usePreferencesPanelReactProps | usePreferencesProps)[] = [] 7 | 8 | // Preferences Panel in XHTML 9 | export function usePreferencesPanel(props: usePreferencesProps) { 10 | 11 | // Check if the panel has been registered already and if an id has been provided 12 | if (preferencesPanels.filter(panel => panel.id === props.id).length > 0 || props.id === "") return 13 | preferencesPanels.push({ ...props, type: "xhtml" }) 14 | 15 | if (getPluginInfo().id === "") return 16 | Zotero.PreferencePanes.register({ 17 | pluginID: getPluginInfo().id, 18 | label: props.label ? props.label : undefined, 19 | image: props.image ? getPluginInfo().rootURI + props.image : undefined, 20 | src: getPluginInfo().rootURI + props.src, 21 | scripts: props.scripts?.map(url => getPluginInfo().rootURI + url) || [], 22 | stylesheets: props.stylesheets?.map(url => getPluginInfo().rootURI + url) || [], 23 | }); 24 | } 25 | 26 | // Preferences Panel in React 27 | export async function usePreferencesPanelReact(props: usePreferencesPanelReactProps) { 28 | 29 | // Check if the panel has been registered already and if an id has been provided 30 | if (preferencesPanels.filter(panel => panel.id === props.id).length > 0 || props.id === "") return 31 | preferencesPanels.push({ ...props, type: "react" }) 32 | 33 | // Generate the pseudo URL for the boilerplate XHTML 34 | 35 | const { id, version, rootURI, referenceName } = getPluginInfo() 36 | 37 | const htmlURL = URL.createObjectURL(new Blob([` 38 | 39 |
40 |
41 |
42 | `], { type: "text/html" })); 43 | 44 | // Generate the JS file url 45 | let scripts = [getPluginInfo().rootURI + `preferences.js`] 46 | if (props.additionalScripts) { 47 | scripts = [...scripts, ...props.additionalScripts?.map(url => getPluginInfo().rootURI + url) || []] 48 | } 49 | 50 | log(getPluginInfo().id) 51 | 52 | // Register the pane 53 | await Zotero.PreferencePanes.register({ 54 | pluginID: getPluginInfo().id, 55 | label: props.label ? props.label : undefined, 56 | image: props.image ? getPluginInfo().rootURI + props.image : undefined, 57 | stylesheets: props.stylesheets?.map(url => getPluginInfo().rootURI + url) || [], 58 | src: htmlURL, 59 | scripts: scripts, 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/hooks/menu.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { MenuItem } from "../../types/types" 3 | import { log } from "../devtools" 4 | 5 | /** 6 | * Creates and appends context menu items to a specified popup element. 7 | * 8 | * @param {string} [selector="zotero-itemmenu"] - The ID of the popup element to which the context menu items will be appended. 9 | * @param {ContextMenuItem[]} [items=[]] - An array of context menu items to be created and appended. 10 | * 11 | * @typedef {Object} ContextMenuItem 12 | * @property {string} type - The type of the menu item (e.g., "item"). 13 | * @property {string} label - The label for the menu item. 14 | * @property {string} [image] - The URL of the image/icon for the menu item. 15 | * @property {Function} [action] - The function to be executed when the menu item is clicked. 16 | */ 17 | export function generateMenuDOM(element: MenuItem) { 18 | 19 | let item = document.createXULElement(`${element.type}`) 20 | switch (element.type) { 21 | case "menuitem": 22 | item.classList.add("menuitem-iconic") 23 | item.setAttribute("label", element.label) 24 | if (element.image) item.setAttribute("image", element.image) 25 | if (element.action) item.addEventListener("command", element.action) 26 | break; 27 | case "menu": 28 | const popup = document.createXULElement("menupopup") 29 | element.children.map(child => { 30 | const childMarkup = generateMenuDOM(child) 31 | popup.appendChild(childMarkup) 32 | }) 33 | item.appendChild(popup) 34 | item.setAttribute("label", element.label) 35 | if (element.image) item.setAttribute("image", element.image) 36 | break; 37 | } 38 | return item 39 | } 40 | /** 41 | * Custom hook to create and manage a context menu. 42 | * 43 | * @param {string} [selector="zotero-itemmenu"] - The ID of the DOM element where the context menu will be appended. 44 | * @param {MenuItem[]} [items=[]] - An array of menu items to be added to the context menu. 45 | * @param {any[]} [deps=[]] - An array of dependencies that will trigger the effect when changed. 46 | * 47 | * @returns {void} 48 | * 49 | * @example 50 | * useContextMenu("custom-menu", [{ label: "Item 1", action: () => console.log("Item 1 clicked") }], [dependency]); 51 | */ 52 | export function useContextMenu(selector: string = "#zotero-itemmenu", items: MenuItem[] = [], deps: any[] = []) { 53 | useEffect(() => { 54 | 55 | const popup = document.querySelector(selector) 56 | Zotero.log("useContextMenu", popup) 57 | if (!popup) return 58 | const elements = items.map(item => { 59 | const element = generateMenuDOM(item) 60 | 61 | if (!popup.lastElementChild) return 62 | popup.lastElementChild.after(element) 63 | return element 64 | }) 65 | return () => { 66 | elements.map(element => element?.remove()) 67 | } 68 | 69 | }, deps) 70 | } -------------------------------------------------------------------------------- /src/components/table.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { useState } from "react" 3 | 4 | // Internal Dependencies 5 | import { useKeyboardShortcut } from "../utils/hooks" 6 | import { getKeyCombination } from "../utils/utils" 7 | 8 | // Types 9 | import { TableProps } from "../types/types" 10 | 11 | // Devtools 12 | import { log } from "../utils/devtools" 13 | 14 | function Table({ columns, data, onDoubleClick = (item: any, index: number) => { } }: TableProps) { 15 | 16 | // States 17 | const [selectedRow, setSelectedRow] = useState(null) 18 | 19 | // Keyboard Shortcuts 20 | useKeyboardShortcut(getKeyCombination("ArrowUp"), () => { 21 | if (selectedRow == null || selectedRow <= 0) return 22 | setSelectedRow(selectedRow - 1) 23 | }) 24 | useKeyboardShortcut(getKeyCombination("ArrowDown"), () => { 25 | if (selectedRow == null || selectedRow >= data.length - 1) return 26 | setSelectedRow(selectedRow + 1) 27 | }) 28 | useKeyboardShortcut(getKeyCombination("Enter"), () => { 29 | if (selectedRow === null) return 30 | onDoubleClick(data[selectedRow], selectedRow) 31 | }) 32 | 33 | return ( 34 | 35 | {/* Header */} 36 | 37 | 38 | {columns.map((header, index) => ( 39 | 44 | ))} 45 | 46 | 47 | 48 | {/* Rows */} 49 | 50 | {data.map((item, index) => ( 51 | { setSelectedRow(index) }} onDoubleClick={() => { onDoubleClick(item, index) }}> 52 | {columns.map((col, cellIndex) => { 53 | const name = col.name as keyof typeof item 54 | const value = (col.labelFn ? col.labelFn(item) : item[name]) as string 55 | 56 | const isSelected = (selectedRow === index) 57 | return ( 58 | 68 | ) 69 | })} 70 | 71 | ))} 72 | 73 |
40 |
41 | {header.label} 42 |
43 |
66 | {value} 67 |
74 | ) 75 | } 76 | 77 | export default Table -------------------------------------------------------------------------------- /src/utils/hooks/prefs.ts: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { useState } from "react" 3 | 4 | // Internal Dependencies 5 | import { getPluginInfo } from "../pluginInfo" 6 | import { JSONFromString } from "../utils" 7 | 8 | // Types 9 | import { usePrefValue, usePrefOptions, usePrefState, usePrefStateValue } from "../../types/types" 10 | import { log } from "../devtools" 11 | import { getZoteroPref, setZoteroPref } from "../prefs" 12 | 13 | // Hook 14 | export function usePref(prefName: string, defaultValue: any = "", options: usePrefOptions = { parseJSON: false, observe: false }): usePrefState { 15 | 16 | // Get the initial value 17 | let initialPrefValue = getZoteroPref(prefName) 18 | 19 | // Use default value if pref is empty 20 | if (!initialPrefValue) { 21 | initialPrefValue = defaultValue 22 | setZoteroPref(prefName, options.parseJSON ? JSON.stringify(defaultValue) : defaultValue) 23 | } else if (options.parseJSON === true) { 24 | initialPrefValue = JSONFromString(initialPrefValue as string, undefined) 25 | } 26 | 27 | // State 28 | const [prefState, setPrefState] = useState(initialPrefValue) 29 | 30 | // Set Pref 31 | const setPref = (value: any) => { 32 | setZoteroPref(prefName, options.parseJSON == true ? JSON.stringify(value) : value) 33 | setPrefState(value) 34 | } 35 | 36 | // Observe the pref for changes 37 | if (options.observe === true) { 38 | Zotero.Prefs.registerObserver(`${getPluginInfo().referenceName}.${name}`, async (value: usePrefStateValue) => { 39 | let newValue = value 40 | if (options.parseJSON === true) { 41 | newValue = JSONFromString(newValue as string, undefined) 42 | } 43 | setPrefState(newValue) 44 | }); 45 | } 46 | 47 | // Return 48 | return [prefState, setPref] 49 | 50 | } 51 | 52 | // Without Hook 53 | export function usePrefWithoutHook(prefName: string, defaultValue: any = "", options: usePrefOptions = { parseJSON: false, observe: false }): Pref { 54 | 55 | // Get the initial value 56 | let initialPrefValue = getZoteroPref(prefName) 57 | 58 | // Use default value if pref is empty 59 | if (!initialPrefValue) { 60 | initialPrefValue = defaultValue 61 | setZoteroPref(prefName, options.parseJSON ? JSON.stringify(defaultValue) : defaultValue) 62 | } else if (options.parseJSON === true) { 63 | initialPrefValue = JSONFromString(initialPrefValue as string, undefined) 64 | } 65 | 66 | // State 67 | // const [prefState, setPrefState] = useState(initialPrefValue) 68 | 69 | // Set Pref 70 | // const setPref = (value: any) => { 71 | // setZoteroPref(prefName, options.parseJSON == true ? JSON.stringify(value) : value) 72 | // setPrefState(value) 73 | // } 74 | 75 | // Observe the pref for changes 76 | // if (options.observe === true) { 77 | // Zotero.Prefs.registerObserver(`${getPluginInfo().referenceName}.${name}`, async (value: usePrefStateValue) => { 78 | // let newValue = value 79 | // if (options.parseJSON === true) { 80 | // newValue = JSONFromString(newValue as string, undefined) 81 | // } 82 | // setPrefState(newValue) 83 | // }); 84 | // } 85 | 86 | // Return 87 | return [initialPrefValue, () => {}] 88 | 89 | } -------------------------------------------------------------------------------- /src/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import { ArticleStatus } from "./types/addon" 2 | import { MenuItem, usePrefStateFunction } from "./types/types" 3 | import { setItemStatus } from "./utils/columns.utils" 4 | import { log } from "./utils/devtools" 5 | import { generateMenuIcon } from "./utils/helpers" 6 | import { generateMenuDOM } from "./utils/hooks/menu" 7 | import { usePrefWithoutHook } from "./utils/hooks/prefs" 8 | import { getPluginInfo } from "./utils/pluginInfo" 9 | import defaultPreferences from "./utils/prefs.default" 10 | 11 | export async function registerReviewContextMenu(id: string, version: string, rootURI: string, referenceName: string) { 12 | // Preferences 13 | const [statusList, setStatusList] = usePrefWithoutHook("statusList", defaultPreferences.statusList, { parseJSON: true, observe: true }) as [ArticleStatus[], usePrefStateFunction] 14 | 15 | log(statusList) 16 | 17 | // Context Menu 18 | registerContextMenu("#zotero-itemmenu", [ 19 | { type: "menuseparator" }, 20 | { 21 | type: "menu", label: "Review", 22 | // image: getPluginInfo().rootURI + "assets/icon.png", 23 | children: [...statusList.map((status: ArticleStatus) => { 24 | return { 25 | type: "menuitem", label: status.label, image: generateMenuIcon(status.color), action: (_) => { 26 | setItemStatus(statusList, status) 27 | } 28 | } 29 | }) as MenuItem[], 30 | { type: "menuseparator" }, 31 | { 32 | type: "menuitem", label: "Edit Review Info", action: () => { 33 | log("Edit Review Info clicked") 34 | // if (typeof Zotero === "undefined" || !Zotero.getMainWindow()) return 35 | // if (typeof (Zotero.getMainWindow() as any).editStatus !== "function") return 36 | Zotero.getMainWindow()[referenceName].editStatus() 37 | } 38 | }, 39 | { type: "menuseparator" }, 40 | { 41 | type: "menuitem", label: "Generate PRISMA Diagram", action: () => { 42 | // if (typeof Zotero === "undefined" || !Zotero.getMainWindow()) return 43 | // if (typeof (Zotero.getMainWindow() as any).generatePRISMADiagram !== "function") return 44 | Zotero.getMainWindow()[referenceName].generatePRISMADiagram() 45 | } 46 | }] 47 | }, 48 | ]) 49 | } 50 | /** 51 | * Custom hook to create and manage a context menu. 52 | * 53 | * @param {string} [selector="zotero-itemmenu"] - The ID of the DOM element where the context menu will be appended. 54 | * @param {MenuItem[]} [items=[]] - An array of menu items to be added to the context menu. 55 | * @param {any[]} [deps=[]] - An array of dependencies that will trigger the effect when changed. 56 | * 57 | * @returns {void} 58 | * 59 | * @example 60 | * useContextMenu("custom-menu", [{ label: "Item 1", action: () => console.log("Item 1 clicked") }], [dependency]); 61 | */ 62 | export async function registerContextMenu(selector: string = "zotero-itemmenu", items: MenuItem[] = []) { 63 | 64 | await log("registerContextMenu called ", selector) 65 | const popup = document.querySelector(selector) 66 | await log("Popup: ", popup) 67 | if (!popup) return 68 | const elements = items.map(item => { 69 | const element = generateMenuDOM(item) 70 | log("Element created:", element) 71 | 72 | if (!popup.lastElementChild) return 73 | popup.lastElementChild.after(element) 74 | return element 75 | }) 76 | return () => { 77 | elements.map(element => element?.remove()) 78 | } 79 | } -------------------------------------------------------------------------------- /src/components/keystrokeInput/keystrokeInputUtils.ts: -------------------------------------------------------------------------------- 1 | // import { log } from "../../utils/devtools"; 2 | 3 | type KeystrokeModifiers = { 4 | alt: boolean; 5 | ctrl: boolean; 6 | meta: boolean; 7 | shift: boolean; 8 | }; 9 | 10 | export const keyStringMac = { 11 | alt: "⌥", 12 | ctrl: "⌃", 13 | meta: "⌘", 14 | shift: "⇧", 15 | }; 16 | export const keyStringWin = { 17 | alt: "Alt", 18 | ctrl: "Ctrl", 19 | meta: "Windows", 20 | shift: "Shift", 21 | }; 22 | export const keyStringLinux = { 23 | alt: "Alt", 24 | ctrl: "Ctrl", 25 | meta: "Super", 26 | shift: "Shift", 27 | }; 28 | 29 | export function getKeyStringByOS() { 30 | if (Zotero.isMac) return keyStringMac; 31 | if (Zotero.isWin) return keyStringWin; 32 | if (Zotero.isLinux) return keyStringLinux; 33 | } 34 | 35 | export const keyString = getKeyStringByOS(); 36 | 37 | export class Keystroke { 38 | modifiers: KeystrokeModifiers = { 39 | alt: false, 40 | ctrl: false, 41 | meta: false, 42 | shift: false, 43 | }; 44 | key: string = ""; 45 | code: string = ""; 46 | 47 | constructor() {} 48 | 49 | static fromString(keystrokeString: string) { 50 | console.log("Fn: Keystroke.fromString"); 51 | const keystroke = new Keystroke(); 52 | keystroke.modifiers.alt = 53 | keystrokeString.includes(keyString!.alt) || 54 | keystrokeString.includes("Alt") 55 | ? true 56 | : false; 57 | keystroke.modifiers.ctrl = 58 | keystrokeString.includes(keyString!.ctrl) || 59 | keystrokeString.includes("Ctrl") 60 | ? true 61 | : false; 62 | keystroke.modifiers.meta = 63 | keystrokeString.includes(keyString!.meta) || 64 | keystrokeString.includes("Meta") 65 | ? true 66 | : false; 67 | keystroke.modifiers.shift = 68 | keystrokeString.includes(keyString!.shift) || 69 | keystrokeString.includes("Shift") 70 | ? true 71 | : false; 72 | keystroke.key = keystrokeString.split("").at(-1) || ""; 73 | keystroke.code = (isNaN(parseInt(keystroke.key)) ? "Key" + keystroke.key : "Digit" + keystroke.key) 74 | return keystroke; 75 | } 76 | 77 | toString(): string { 78 | let modifiers = ""; 79 | if (this.modifiers.alt) modifiers += keyString?.alt + " "; 80 | if (this.modifiers.ctrl) modifiers += keyString?.ctrl + " "; 81 | if (this.modifiers.meta) modifiers += keyString?.meta + " "; 82 | if (this.modifiers.shift) modifiers += keyString?.shift + " "; 83 | const strKeystroke = modifiers + this.code; 84 | return strKeystroke; 85 | } 86 | 87 | toJSON(): string { 88 | const data = { 89 | modifiers: this.modifiers, 90 | code: this.code, 91 | }; 92 | return JSON.stringify(data); 93 | } 94 | } 95 | 96 | interface HTMLKeystrokeInputElement extends HTMLInputElement { 97 | keystrokeValue: Keystroke; 98 | } 99 | 100 | const invalidMainKeys = [ 101 | "Shift", 102 | "Alt", 103 | "Control", 104 | "Meta", 105 | "ContextMenu", 106 | "NumLock", 107 | "ScrollLock", 108 | "VolumeMute", 109 | "VolumeDown", 110 | "VolumeUp", 111 | "MediaSelect", 112 | "LaunchApp1", 113 | "LaunchApp2", 114 | ]; 115 | 116 | export function isMainKeyValid(key: string) { 117 | let valid = true; 118 | if (invalidMainKeys.includes(key)) { 119 | valid = false; 120 | } 121 | return valid; 122 | } -------------------------------------------------------------------------------- /src/utils/hooks/dialog.ts: -------------------------------------------------------------------------------- 1 | import { log } from "../devtools"; 2 | import { getPluginInfo } from "../pluginInfo"; 3 | 4 | // TODO: Implement 5 | export function useDialog(title: string, message: string, buttons: string[], onbutton: (button: number) => void) { 6 | // Open a dialog window 7 | let dialogWindow = window.openDialog("about:blank", "_blank", "chrome,width=400,height=300") 8 | 9 | // Check if the window was successfully created 10 | if (dialogWindow) { 11 | // Write the basic HTML structure into the dialog 12 | dialogWindow.document.write(` 13 | 14 | 15 | 16 | ${title} 17 | 18 | 19 |

Hello, Dialog!

20 |

This is a dialog with a script!

21 | 22 | 23 | `) 24 | } 25 | 26 | // // Create a script element 27 | // let script = dialogWindow.document.createElement("script"); 28 | // script.type = "text/javascript"; 29 | // script.textContent = ` 30 | // console.log("Script is running in the dialog!"); 31 | // alert("This is a script inside the dialog!"); 32 | // `; 33 | 34 | // // Append the script to the dialog's document 35 | // dialogWindow.document.body.appendChild(script); 36 | // } else { 37 | // console.error("Failed to create dialog window."); 38 | // } 39 | 40 | return; 41 | 42 | 43 | // log(` 44 | // 45 | // 46 | // 47 | // 48 | // 49 | // 50 | // 51 | 52 | // 56 | 57 | // 59 | // asdmisdnaodsa 60 | // 61 | // 62 | // `) 63 | // const dialogURL = URL.createObjectURL(new Blob([` 64 | // 65 | // 66 | // 67 | // 68 | // 69 | // 70 | // 71 | 72 | // 76 | 77 | // 79 | // asdmisdnaodsa 80 | // 81 | // 82 | // `], { type: "text/html" })); 83 | // log(dialogURL) 84 | 85 | // // const dialogURL = `data:application/xhtml+xml;charset=utf-8,${encodeURIComponent(dialogXHTML)}`; 86 | // // return window.openDialog(dialogURL, '', 'chrome,modal,width=520,height=240'); 87 | // // return window.openDialog(`chrome://${globals.info.referenceName}/content/dialog.xhtml`, "_blank"); 88 | // // return window.openDialog(dialogURL, '', 'chrome,modal,width=520,height=240'); 89 | // return window.openDialog(`chrome://${globals.info.referenceName}/content/dialog.xhtml`, '', 'chrome,modal,width=520,height=240'); 90 | } -------------------------------------------------------------------------------- /src/extraColumns.ts: -------------------------------------------------------------------------------- 1 | import { ArticleStatus, PRISMACategory } from "./types/addon" 2 | import { getPRISMALabel, registerColumn, unregisterAllColumns } from "./utils/columns.utils" 3 | import { getPluginInfo } from "./utils/pluginInfo" 4 | import { getZoteroPref } from "./utils/prefs" 5 | 6 | import { JSONFromString } from "./utils/utils" 7 | 8 | export async function registerExtraColumns(id: string) { 9 | 10 | await unregisterAllColumns() 11 | if (!Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns) { 12 | Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns = [] 13 | } 14 | 15 | // Prefs 16 | const statusList = JSONFromString(getZoteroPref("statusList") as string) as ArticleStatus[] 17 | const prismaCategories = JSONFromString(getZoteroPref("prismaCategories") as string) as PRISMACategory[] 18 | const prismaTagPrefix = getZoteroPref("prismaTagPrefix") as string 19 | const commentsTagPrefix = getZoteroPref("commentsTagPrefix") as string 20 | 21 | // await log("Prefs") 22 | // await log("- statusList", statusList) 23 | // await log("- prismaCategories", prismaCategories) 24 | // await log("- prismaTagPrefix", prismaTagPrefix) 25 | // await log("- commentsTagPrefix", commentsTagPrefix) 26 | 27 | // Hooks 28 | // -- Status 29 | const statusDataKey = await registerColumn({ 30 | pluginID: id, 31 | dataKey: 'status', 32 | label: 'Status', 33 | flex: 1, 34 | minWidth: 50, 35 | width: "100px", 36 | zoteroPersist: ["width", "hidden", "sortDirection"], 37 | dataProvider: (item: Zotero.Item, _) => { 38 | return JSON.stringify(statusList.filter(status => item.hasTag(status.tag))) 39 | }, 40 | renderCell: (index: number, rawData: string, column: any): HTMLElement => { 41 | // Get the data 42 | try { 43 | const data = JSON.parse(rawData) as ArticleStatus[] 44 | 45 | // Create the cell element 46 | const element = Zotero.getMainWindow().document.createElement("span") 47 | element.className = `cell ${column.className}` // Do not remove this or the column will look weird 48 | 49 | // Set the innerHTML 50 | data.map(item => { 51 | const element = Zotero.getMainWindow().document.createElement("span") 52 | element.className = `rt-badge ${item.invertTextColor ? "invert-text" : ""}` 53 | element.style.backgroundColor = item.color 54 | element.innerHTML = item.label 55 | return element 56 | }).forEach(spanElement => { 57 | element.append(spanElement) 58 | }) 59 | 60 | // Return the created cell element 61 | return element 62 | } catch { 63 | const fallbackElement = Zotero.getMainWindow().document.createElement("span") 64 | fallbackElement.className = `cell ${column.className}` // Do not remove this or the column will look weird 65 | return fallbackElement 66 | } 67 | } 68 | }) 69 | 70 | // -- PRISMA 71 | const prismaDataKey = await registerColumn({ 72 | pluginID: id, 73 | dataKey: 'prisma', 74 | label: 'PRISMA Category', 75 | flex: 1, 76 | minWidth: 100, 77 | width: "200px", 78 | zoteroPersist: ["width", "hidden", "sortDirection"], 79 | dataProvider: (item: Zotero.Item, _: string) => { 80 | try { 81 | let res = item.getTags().filter(tag => prismaCategories.find(cat => cat.tag == tag.tag)) 82 | if (res.length < 1) { 83 | return "" 84 | } 85 | return getPRISMALabel(res[0].tag.replace(prismaTagPrefix, "")) 86 | } catch { 87 | return "" 88 | } 89 | } 90 | }) 91 | 92 | // -- Comments 93 | const commentsDataKey = await registerColumn({ 94 | pluginID: id, 95 | dataKey: 'comments', 96 | label: 'Status Comments', 97 | flex: 1, 98 | minWidth: 100, 99 | width: "200px", 100 | zoteroPersist: ["width", "hidden", "sortDirection"], 101 | dataProvider: (item: Zotero.Item, _: string) => { 102 | let res = item.getTags().filter(tag => tag.tag.startsWith(commentsTagPrefix)) 103 | if (res.length < 1) { 104 | return "" 105 | } 106 | return res[0].tag.replace(commentsTagPrefix, "") 107 | } 108 | }) 109 | 110 | return { statusDataKey, prismaDataKey, commentsDataKey } 111 | } -------------------------------------------------------------------------------- /src/utils/columns.utils.ts: -------------------------------------------------------------------------------- 1 | import { ArticleStatus, PRISMACategory } from "../types/addon" 2 | import { log } from "./devtools" 3 | import { getPluginInfo } from "./pluginInfo" 4 | 5 | 6 | // const extraColumns: string[] = [] 7 | // export const extraColumns: string[] = [] 8 | export async function registerColumn(options: _ZoteroTypes.ItemTreeManager.ItemTreeColumnOptions) { 9 | log("columns.utils: registerColumn", options) 10 | // log(Object.keys(Zotero.getMainWindow())) 11 | // log(getPluginInfo().referenceName) 12 | if (!Zotero.getMainWindow()[getPluginInfo().referenceName]) { return; } 13 | if (!Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns) { return; } 14 | // const extraColumns: string[] = [] 15 | // Ensure the column options includes dataKey 16 | if (Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns.includes(options.dataKey)) return 17 | 18 | // const pluginID = getPluginInfo().id + "-" + extraColumns.length 19 | 20 | // Column Options 21 | const columnOptions = { ...options } //, pluginID } 22 | 23 | const registerColumnFn = 24 | Zotero.ItemTreeManager.registerColumn || 25 | Zotero.ItemTreeManager.registerColumns; 26 | // @ts-ignore 27 | const dataKey = await registerColumnFn.apply(Zotero.ItemTreeManager, [{ ...columnOptions }]) 28 | // const dataKey = await Zotero.ItemTreeManager.registerColumn(columnOptions) 29 | if (!dataKey) return; 30 | 31 | Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns.push(dataKey) 32 | // await Zotero.ItemTreeManager.refreshColumns() 33 | } 34 | 35 | export async function unregisterAllColumns() { 36 | Zotero.getMainWindow().console.log(Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns) 37 | if (!Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns) return 38 | if (Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns.length === 0) return 39 | await Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns.map(async (dataKey: string) => { 40 | await Zotero.ItemTreeManager.unregisterColumn(dataKey) 41 | }) 42 | Zotero.getMainWindow()[getPluginInfo().referenceName].extraColumns = [] 43 | } 44 | 45 | export function setItemStatus(statusList: ArticleStatus[], status: ArticleStatus) { 46 | 47 | // Get the selected items 48 | const selectedArticles = ZoteroPane.getSelectedItems() 49 | replaceTags({ 50 | articles: selectedArticles, 51 | removeTags: statusList.map(stat => stat.tag) as string[], 52 | newTags: status ? [status.tag] : [] 53 | }) 54 | 55 | // // Loop through the articles and set the status 56 | // selectedArticles.map(article => { 57 | 58 | // // Remove the old status tag 59 | // statusList.map(stat => { 60 | // if (article.hasTag(stat.tag)) article.removeTag(stat.tag) 61 | // }) 62 | // // statuses.filter(status => item.hasTag(status.tag)) 63 | // // Add the new status tag 64 | // if (status.tag != "") article.addTag(status.tag) 65 | 66 | // // Save the article 67 | // article.saveTx() 68 | // }) 69 | } 70 | 71 | export function setItemComments(commentsTagPrefix: string, comments: string) { 72 | 73 | // Selected articles 74 | const selectedArticles = ZoteroPane.getSelectedItems() 75 | 76 | // Get the reason tag 77 | const reasonTag = commentsTagPrefix + comments; 78 | 79 | // Update the exclusion criteria 80 | for (const item of selectedArticles) { 81 | // Remove the exclusion criteria 82 | item.getTags().map((tag) => { 83 | if (tag.tag.includes(commentsTagPrefix)) item.removeTag(tag.tag); 84 | }); 85 | 86 | if (comments != "") { 87 | item.addTag(reasonTag); 88 | } 89 | item.saveTx(); 90 | } 91 | } 92 | 93 | export function getPRISMALabel(value: string): string { 94 | return value.replace(":", " ›") 95 | } 96 | 97 | export function setItemPRISMACategory(categoryList: PRISMACategory[], newCategory: PRISMACategory) { 98 | 99 | // Selected articles 100 | const selectedArticles = ZoteroPane.getSelectedItems() 101 | 102 | replaceTags({ 103 | articles: selectedArticles, 104 | removeTags: categoryList.map(stat => stat.tag) as string[], 105 | newTags: newCategory ? [newCategory.tag] : [] 106 | }) 107 | 108 | } 109 | 110 | type ReplaceTagsArgs = { 111 | articles: Zotero.Item[], 112 | removeTags: string[], 113 | newTags: string[] 114 | } 115 | function replaceTags({ articles, removeTags, newTags }: ReplaceTagsArgs) { 116 | 117 | // Loop through the articles and set the status 118 | articles.map(article => { 119 | 120 | // Remove the old status tag 121 | removeTags.map(tag => { 122 | if (article.hasTag(tag)) article.removeTag(tag) 123 | }) 124 | 125 | // Add the new status tag 126 | 127 | // Remove the old status tag 128 | newTags.map(tag => { 129 | if (tag && tag != "") article.addTag(tag) 130 | }) 131 | 132 | // Save the article 133 | article.saveTx() 134 | }) 135 | } -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { Ref, RefObject } from "react" 3 | import { KeyCombination } from "../types/types" 4 | import { ArticleStatus } from "../types/addon" 5 | 6 | /** 7 | * Converts a keyboard event into a string representation of the key combination. 8 | * 9 | * This function takes a `KeyboardEvent` object and returns a string that represents 10 | * the combination of keys pressed during the event. The string includes the names 11 | * of modifier keys (Ctrl, Meta, Alt, Shift) followed by the main key code. 12 | * 13 | * @param event - The keyboard event to convert. 14 | * @returns A string representing the key combination. 15 | */ 16 | export function getStringFromKeyboardEvent(event: KeyboardEvent): string { 17 | let combination = [] 18 | let code = event.code.replace("Key", "").replace("Digit", "") 19 | if (event.ctrlKey) combination.push("Ctrl") 20 | if (event.metaKey) combination.push("Meta") 21 | if (event.altKey) combination.push("Alt") 22 | if (event.shiftKey) combination.push("Shift") 23 | combination.push(code) 24 | return combination.join(" ") 25 | } 26 | 27 | export const keyStringMac = { 28 | alt: "⌥", 29 | ctrl: "⌃", 30 | meta: "⌘", 31 | shift: "⇧", 32 | }; 33 | export const keyStringWin = { 34 | alt: "Alt", 35 | ctrl: "Ctrl", 36 | meta: "Windows", 37 | shift: "Shift", 38 | }; 39 | export const keyStringLinux = { 40 | alt: "Alt", 41 | ctrl: "Ctrl", 42 | meta: "Super", 43 | shift: "Shift", 44 | }; 45 | 46 | export function getKeyStringByOS() { 47 | if (Zotero.isMac) return keyStringMac; 48 | if (Zotero.isWin) return keyStringWin; 49 | if (Zotero.isLinux) return keyStringLinux; 50 | } 51 | 52 | const keystring = getKeyStringByOS() 53 | 54 | export function getStringFromKeyCombination(keys: KeyCombination): string { 55 | 56 | let combination = [] 57 | let code = keys.code.replace("Key", "").replace("Digit", "") 58 | if (keys.ctrlKey) combination.push(keystring?.ctrl) 59 | if (keys.metaKey) combination.push(keystring?.meta) 60 | if (keys.altKey) combination.push(keystring?.alt) 61 | if (keys.shiftKey) combination.push(keystring?.shift) 62 | combination.push(code) 63 | return combination.join(" ") 64 | } 65 | 66 | /** 67 | * Parses a keystroke string and returns an object representing the key combination. 68 | * 69 | * @param keystroke - A string representing the keystroke combination (e.g., "Ctrl Alt A"). 70 | * @returns An object representing the key combination with properties for control keys and the key code. 71 | * 72 | * @example 73 | * ```typescript 74 | * const combination = getKeyCombination("Ctrl Alt A"); 75 | * // combination will be: 76 | * // { 77 | * // ctrlKey: true, 78 | * // altKey: true, 79 | * // shiftKey: false, 80 | * // metaKey: false, 81 | * // code: "KeyA" 82 | * // } 83 | * ``` 84 | */ 85 | export function getKeyCombination(keystroke: string): KeyCombination { 86 | const keys = keystroke.split(" ") 87 | let code = keys[keys.length - 1] 88 | // If letter, add the Key prefix 89 | if (/[a-zA-Z]/g.test(code) && code.length === 1) { 90 | code = "Key" + code.toUpperCase() 91 | } else if (/^\d$/.test(code)) { 92 | code = "Digit" + code 93 | } 94 | return { 95 | ctrlKey: keys.includes("Ctrl"), 96 | altKey: keys.includes("Alt"), 97 | shiftKey: keys.includes("Shift"), 98 | metaKey: keys.includes("Meta"), 99 | code: code 100 | } 101 | } 102 | 103 | /** 104 | * Parses a JSON string and returns the corresponding object. 105 | * If the string cannot be parsed, returns a default value. 106 | * 107 | * @param {string} jsonString - The JSON string to parse. 108 | * @param {any} defaultValue - The default value to return if parsing fails. 109 | * @returns {any} The parsed object or the default value. 110 | */ 111 | export function JSONFromString(jsonString: string, defaultValue: any = {}): any { 112 | let obj = defaultValue 113 | try { 114 | obj = JSON.parse(jsonString) || defaultValue 115 | } catch (e) { 116 | obj = defaultValue 117 | } 118 | return obj 119 | } 120 | 121 | export async function getTagsFromCurrentLibrary(): Promise { 122 | const tags = await Zotero.Tags.getAll(Zotero.Libraries.userLibraryID) 123 | return Array.from(new Set(tags.map(tag => tag.tag))) 124 | } 125 | 126 | export function mergeRefs(...refs: Ref[]) { 127 | return (value) => { 128 | refs.forEach(ref => { 129 | if (typeof ref === 'function') { 130 | ref(value); 131 | } else if (ref != null) { 132 | ref.current = value; 133 | } 134 | }); 135 | }; 136 | } 137 | 138 | export function getStatus(item: Zotero.Item, statusList: ArticleStatus[]) { 139 | const tags = item.getTags() 140 | return statusList.find(status => 141 | tags.findIndex(tag => status.tag == tag.tag) != -1 142 | ) 143 | } 144 | export function getMatchingTag(item: Zotero.Item, tagList: string[]): string | undefined { 145 | const tags = item.getTags() 146 | return tagList.find(listTag => 147 | tags.findIndex(itemTag => itemTag.tag == listTag) != -1 148 | ) 149 | } 150 | 151 | export function countArticlesWithTag(articles: Zotero.Item[], tag: string) { 152 | return articles.reduce((count, article) => count + (article.hasTag(tag) ? 1 : 0), 0) 153 | } 154 | 155 | export function getComments(commentsTagPrefix: string, article: Zotero.Item) { 156 | return article.getTags() 157 | .map(tag => tag.tag) 158 | .filter(tag => tag.startsWith(commentsTagPrefix)) 159 | } -------------------------------------------------------------------------------- /src/views/commentsView.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form" 2 | 3 | // Define the form data structure 4 | interface FormData { 5 | comments: string; 6 | } 7 | import Modal from "../components/modal" 8 | import { setItemComments } from "../utils/columns.utils" 9 | import { usePref } from "../utils/hooks" 10 | import { ReactElement, Ref, useImperativeHandle, useRef, useState } from "react" 11 | import { usePrefStateFunction } from "../types/types" 12 | import { getTagsFromCurrentLibrary, mergeRefs } from "../utils/utils"; 13 | import { AutocompleteComponent, useAutocomplete } from "../components/hooks/autocomplete"; 14 | import defaultPreferences from "../utils/prefs.default"; 15 | 16 | export interface CommentsViewComponent { 17 | editComments: () => void 18 | } 19 | export default function CommentsView({ ref }: { ref: Ref }): ReactElement & CommentsViewComponent { 20 | 21 | // Prefs 22 | const [commentsTagPrefix, _] = usePref("commentsTagPrefix", defaultPreferences.commentsTagPrefix, { observe: true }) as [string, usePrefStateFunction] 23 | 24 | // States 25 | const [isCommentsModalOpen, setIsCommentsModalOpen] = useState(false) 26 | const [commentList, setCommentList] = useState([]) 27 | 28 | // Refs 29 | const inputRef = useRef(null) 30 | const autocompleteRef = useRef(null) 31 | 32 | // Form 33 | const { register, control, handleSubmit, setValue, setFocus } = useForm() 34 | 35 | // Submit 36 | const onSubmit = ({ comments }: { comments: string }) => { 37 | 38 | setItemComments(commentsTagPrefix, comments) 39 | 40 | // Close the modal 41 | setIsCommentsModalOpen(false) 42 | } 43 | 44 | // Expose the inputValueRef to the parent ref 45 | useImperativeHandle(ref, () => ({ 46 | ...inputRef.current as HTMLInputElement, 47 | editComments: async () => { 48 | 49 | const selectedArticles = ZoteroPane.getSelectedItems() 50 | 51 | // Get all comments for autocompletion 52 | try { 53 | const allTags = await getTagsFromCurrentLibrary() 54 | const filteredTags = allTags.filter((tag: string) => 55 | tag.startsWith(commentsTagPrefix) 56 | ) 57 | .map(comment => comment.replace(commentsTagPrefix, "")) 58 | setCommentList(filteredTags) 59 | } catch (error) { 60 | console.error("Error getting tags from library:", error) 61 | } 62 | 63 | if (selectedArticles.length > 1) { 64 | setValue("comments", "") 65 | } else if (selectedArticles.length === 1) { 66 | const filteredCommentTags = selectedArticles[0] 67 | .getTags() 68 | .filter(tag => tag.tag.startsWith(commentsTagPrefix)) 69 | 70 | let currentComment = "" 71 | if (filteredCommentTags.length > 0) { 72 | currentComment = filteredCommentTags[0].tag.replace(commentsTagPrefix, "") 73 | } 74 | 75 | setValue("comments", currentComment) 76 | } 77 | 78 | setIsCommentsModalOpen(true) 79 | } 80 | })) 81 | 82 | // Input props 83 | const inputProps = register("comments") 84 | 85 | // Autocomplete 86 | const handleSelection = (suggestion: string) => { 87 | setValue("comments", suggestion) 88 | } 89 | const autocomplete = useAutocomplete(commentList, handleSelection) 90 | const onFocus = () => { 91 | setFocus("comments") 92 | } 93 | const onBlur = () => { 94 | setTimeout(() => { autocomplete.setIsOpen(false) }, 100) 95 | } 96 | 97 | return <> 98 | { 99 | setFocus("comments") 100 | }}> 101 |
104 | 105 |
106 | 116 |
117 | {autocomplete.suggestions.map((suggestion, index) => ( 118 |
{ console.log("SELECT", suggestion); handleSelection(suggestion) }}> 119 | {suggestion} 120 |
121 | ))} 122 |
123 |
124 | 125 |
126 | 127 | 128 |
129 |
130 |
131 | 132 | } -------------------------------------------------------------------------------- /src/components/modal.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { ReactNode, Ref, useEffect, useImperativeHandle, useRef } from "react" 3 | 4 | // Internal Dependencies 5 | import { useKeyboardShortcut } from "../utils/hooks" 6 | import { getKeyCombination } from "../utils/utils" 7 | 8 | // Devtools 9 | import { log } from "../utils/devtools" 10 | import { X } from "lucide-react" 11 | 12 | type ModalProps = { 13 | ref?: Ref, 14 | children: ReactNode, 15 | 16 | openState: boolean, 17 | setOpenState: (openState: boolean) => void, 18 | 19 | title?: string, 20 | onOpen?: () => void, 21 | onClose?: () => void 22 | } 23 | 24 | export interface ModalComponent { 25 | close: () => void 26 | } 27 | function Modal({ ref, children, openState, setOpenState, title, onOpen, onClose }: ModalProps): ReactElement & ModalComponent { 28 | 29 | // Open 30 | useEffect(() => { 31 | if (openState == true && onOpen) { 32 | onOpen() 33 | } 34 | // previousOpenState.current = openState 35 | }, [openState]); 36 | 37 | // Close 38 | const close = () => { 39 | setOpenState(false) 40 | if (onClose) onClose() 41 | } 42 | 43 | const onClickHandler = (event: React.MouseEvent) => { 44 | if (event.target == event.currentTarget) { 45 | close() 46 | // setOpenState(false) 47 | // if (onClose) onClose() 48 | } 49 | } 50 | 51 | // Keyboard Shortcut 52 | useKeyboardShortcut(getKeyCombination("Escape"), () => { close() }) 53 | 54 | useImperativeHandle(ref, () => ({ 55 | close: close 56 | })) 57 | 58 | return ( 59 |
69 |
75 |
76 | {title ? ( 77 |
78 |

79 | {title} 80 |

81 |
82 | ) : ""} 83 |
84 | {children} 85 |
86 |
87 |
88 |
89 | ) 90 | } 91 | 92 | export default Modal 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | // const modalTemplate = Handlebars.compile(` 109 | //
110 | //
111 | //
112 | // 115 | // 116 | //
117 | // 119 | //
120 | //
121 | // `); 122 | 123 | // // Main modal function 124 | // export function createModal( 125 | // id: string, 126 | // title: string, 127 | // content: HTMLElement, 128 | // options: ModalOptions = {}, 129 | // ) { 130 | // // Process the template and generate the modal HTML element 131 | // const modalElement = document.createElement("div"); 132 | // modalElement.setAttribute("aria-hidden", "true"); 133 | // modalElement.id = id; 134 | // modalElement.className = "modal"; 135 | // modalElement.innerHTML = modalTemplate({ id, title }); 136 | 137 | // modalElement.querySelector(".modal-content")?.appendChild(content); 138 | 139 | // // Create a modal class 140 | // const modal = new Modal(id, modalElement, options); 141 | // return modal; 142 | // } 143 | 144 | // export function initModal() {} 145 | 146 | // type ModalOptions = { 147 | // onClose?: () => void; 148 | // }; 149 | 150 | // // Modal class 151 | // class Modal { 152 | // id: string; 153 | // root?: HTMLElement | Document; 154 | // element: HTMLElement; 155 | // options: ModalOptions; 156 | // constructor(id: string, element: HTMLElement, options?: ModalOptions) { 157 | // this.id = id; 158 | // this.element = element; 159 | // this.options = options as ModalOptions; 160 | // } 161 | // appendTo(root: HTMLElement | Document) { 162 | // root.appendChild(this.element); 163 | // this.bindEvents(); 164 | // this.root = root; 165 | // return this; 166 | // } 167 | // open() { 168 | // this.element.classList.add("open"); 169 | 170 | // // registerEventListener(this.root?.parentNode, 'keydown', this.closeKeyStroke.bind(this)) 171 | // this.root?.parentNode?.addEventListener( 172 | // "keydown", 173 | // this.closeKeyStroke.bind(this), 174 | // ); 175 | 176 | // // Autofocus 177 | // const autofocusElement = 178 | // this.element.querySelector('[autofocus="true"]'); 179 | // if (autofocusElement) { 180 | // (autofocusElement as HTMLElement).focus(); 181 | // } 182 | // } 183 | // closeKeyStroke(ev: any) { 184 | // if (ev.key === "Escape") { 185 | // this.close(); 186 | // ev.preventDefault(); 187 | // } 188 | // } 189 | // close() { 190 | // this.element.classList.remove("open"); 191 | // // deregisterEventListener(this.root?.parentNode, 'keydown', this.closeKeyStroke.bind(this)) 192 | // this.root?.parentNode?.removeEventListener( 193 | // "keydown", 194 | // this.closeKeyStroke, 195 | // ); 196 | 197 | // // Focus on the main element when closing 198 | // // if (this.options?.onCloseFocus) { 199 | // // this.options.onCloseFocus.focus(); 200 | // // } 201 | // if (this.options?.onClose) { 202 | // this.options?.onClose(); 203 | // } 204 | // } 205 | // bindEvents() { 206 | // // Close buttons 207 | // const closeActionElements = 208 | // this.element.querySelectorAll("[action=close]"); 209 | // for (const el of closeActionElements) { 210 | // // registerEventListener(el, 'click', (ev: Event) => { 211 | // // this.close(); 212 | // // }) 213 | // el.addEventListener("click", (ev: Event) => { 214 | // this.close(); 215 | // }); 216 | // } 217 | 218 | // // Close background 219 | // // registerEventListener(this.element, 'click', (ev) => { 220 | // // if (ev.target == this.element) this.close(); 221 | // // }) 222 | // this.element.onclick = (ev) => { 223 | // if (ev.target == this.element) this.close(); 224 | // }; 225 | // } 226 | // } 227 | -------------------------------------------------------------------------------- /src/addon.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | // Internal Dependencies 3 | import defaultPreferences from "./utils/prefs.default" 4 | import { useContextMenu, useExtraColumn, useKeyboardShortcut, usePref, usePreferencesPanelReact } from "./utils/hooks" 5 | 6 | // Types 7 | import { MenuItem, usePrefStateFunction } from "./types/types" 8 | import { ArticleStatus, PRISMACategory } from "./types/addon" 9 | 10 | // Devtools 11 | import { generateMenuIcon } from "./utils/helpers" 12 | import { setItemStatus, getPRISMALabel } from "./utils/columns.utils" 13 | import { use, useEffect, useRef } from "react" 14 | import StatusView, { StatusViewComponent } from "./views/statusView" 15 | import PRISMAView, { PRISMAViewComponent } from "./views/prismaView" 16 | import { log } from "./utils/devtools" 17 | import { getPluginInfo } from "./utils/pluginInfo" 18 | import { registerExtraColumns } from "./extraColumns" 19 | import { registerReviewContextMenu } from "./contextMenu" 20 | 21 | export default function Addon() { 22 | 23 | // Preferences 24 | // TODO: FIX 25 | // usePreferencesPanelReact({ 26 | // id: "main", // Has to match the file name (e.g., .src/preferences/main.pn.tsx => id: "main") 27 | // label: "Zotero Review Assistant", 28 | // image: "assets/icons/favicon.svg", 29 | // stylesheets: ["styles/styles.css"] 30 | // }) 31 | 32 | // Prefs 33 | const [statusList, setStatusList] = usePref("statusList", defaultPreferences.statusList, { parseJSON: true, observe: true }) as [ArticleStatus[], usePrefStateFunction] 34 | 35 | // Extra Columns 36 | // -- Status 37 | 38 | // -- PRISMA Category 39 | const [prismaCategories,] = usePref("prismaCategories", defaultPreferences.prismaCategories, { parseJSON: true, observe: true }) as [PRISMACategory[], usePrefStateFunction] 40 | const [prismaTagPrefix,] = usePref("prismaTagPrefix", defaultPreferences.prismaTagPrefix, { observe: true }) as [string, usePrefStateFunction] 41 | 42 | // -- Comments 43 | const [commentsTagPrefix,] = usePref("commentsTagPrefix", defaultPreferences.commentsTagPrefix, { observe: true }) as [string, usePrefStateFunction] 44 | 45 | // Hooks 46 | // -- Status 47 | // useExtraColumn({ 48 | // dataKey: 'status', 49 | // label: 'Status', 50 | // dataProvider: (item: Zotero.Item, _) => { 51 | // return JSON.stringify(statusList.filter(status => item.hasTag(status.tag))) 52 | // }, 53 | // renderCell: (index: number, rawData: string, column: any): HTMLElement => { 54 | // // Get the data 55 | // try { 56 | // const data = JSON.parse(rawData) as ArticleStatus[] 57 | 58 | // // Create the cell element 59 | // const element = document.createElement("span") 60 | // element.className = `cell ${column.className}` // Do not remove this or the column will look weird 61 | 62 | // // Set the innerHTML 63 | // element.innerHTML = data.map(item => `${item.label}`).join("") 64 | 65 | // // Return the created cell element 66 | // return element 67 | // } catch { 68 | // const fallbackElement = document.createElement("span") 69 | // fallbackElement.className = `cell ${column.className}` // Do not remove this or the column will look weird 70 | // return fallbackElement 71 | // } 72 | // } 73 | // }) 74 | // // -- PRISMA 75 | // useExtraColumn({ 76 | // dataKey: 'prisma', 77 | // label: 'PRISMA Category', 78 | // dataProvider: (item: Zotero.Item, _: string) => { 79 | // try { 80 | // let res = item.getTags().filter(tag => prismaCategories.find(cat => cat.tag == tag.tag)) 81 | // if (res.length < 1) { 82 | // return "" 83 | // } 84 | // return getPRISMALabel(res[0].tag.replace(prismaTagPrefix, "")) 85 | // } catch { 86 | // return "" 87 | // } 88 | // } 89 | // }) 90 | // // -- Comments 91 | // useExtraColumn({ 92 | // dataKey: 'comments', 93 | // label: 'Status Comments', 94 | // dataProvider: (item: Zotero.Item, _: string) => { 95 | // let res = item.getTags().filter(tag => tag.tag.startsWith(commentsTagPrefix)) 96 | // if (res.length < 1) { 97 | // return "" 98 | // } 99 | // return res[0].tag.replace(commentsTagPrefix, "") 100 | // } 101 | // }) 102 | 103 | // Context Menu 104 | // useContextMenu("#zotero-itemmenu", [ 105 | // { type: "menuseparator" }, 106 | // { 107 | // type: "menu", label: "Review", 108 | // // image: getPluginInfo().rootURI + "assets/icon.png", 109 | // children: [...statusList.map((status: ArticleStatus) => { 110 | // return { 111 | // type: "menuitem", label: status.label, image: generateMenuIcon(status.color), action: (_) => { 112 | // setItemStatus(statusList, status) 113 | // } 114 | // } 115 | // }) as MenuItem[], 116 | // { type: "menuseparator" }, 117 | // { 118 | // type: "menuitem", label: "Edit Review Info", action: () => { 119 | // editStatus() 120 | // } 121 | // }, 122 | // { type: "menuseparator" }, 123 | // { 124 | // type: "menuitem", label: "Generate PRISMA Diagram", action: () => { 125 | // generatePRISMADiagram() 126 | // } 127 | // }] 128 | // }, 129 | // ], [statusList]) 130 | 131 | // Keyboard Shortcuts 132 | // -- Status 133 | for (let status of statusList) { 134 | if (status.keystroke == null) continue 135 | useKeyboardShortcut(status.keystroke, () => { 136 | setItemStatus(statusList, status) 137 | }) 138 | } 139 | 140 | // Refs 141 | const statusViewRef = useRef(null) 142 | const prismaViewRef = useRef(null) 143 | 144 | // Open modal functions 145 | const editStatus = () => { 146 | statusViewRef.current?.edit() 147 | } 148 | const generatePRISMADiagram = () => { 149 | prismaViewRef.current?.open() 150 | } 151 | useEffect(() => { 152 | // Register functions globally 153 | const runAsync = async () => { 154 | 155 | log("Registering global functions") 156 | 157 | if (!Zotero.getMainWindow()[getPluginInfo().referenceName]) { return; } 158 | 159 | log("Zotero.getMainWindow()[getPluginInfo().referenceName] exists") 160 | Zotero.getMainWindow()[getPluginInfo().referenceName].editStatus = editStatus 161 | Zotero.getMainWindow()[getPluginInfo().referenceName].generatePRISMADiagram = generatePRISMADiagram 162 | 163 | // Hooks 164 | const { id, version, rootURI, referenceName } = getPluginInfo() 165 | 166 | // Register Extra Columns 167 | await registerExtraColumns(id) 168 | window.colFn = registerExtraColumns 169 | 170 | log(registerExtraColumns) 171 | log(id) 172 | 173 | // Register Context Menu 174 | await registerReviewContextMenu(id, version, rootURI, referenceName) 175 | 176 | // Preferences 177 | await usePreferencesPanelReact({ 178 | id: "main", // Has to match the file name (e.g., .src/preferences/main.pn.tsx => id: "main") 179 | label: "Zotero Review Assistant", 180 | image: "assets/icons/favicon.svg", 181 | stylesheets: ["styles/styles.css"] 182 | }) 183 | 184 | // Inject the CSS styles 185 | // const styles = document.createElement("link") 186 | // styles.href = `${rootURI}/styles/styles.css` 187 | // styles.rel = "stylesheet" 188 | // document.documentElement.appendChild(styles) 189 | } 190 | runAsync() 191 | }, []) 192 | 193 | 194 | // setTimeout(async () => { 195 | // await deregisterColumns() 196 | // }, 10000) 197 | 198 | // console.log("REFRESH") 199 | return <> 200 | 201 | 202 | 203 | 204 | } -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { showFilePicker } from "./helpers"; 2 | import { getPluginInfo } from "./pluginInfo"; 3 | 4 | // File picker 5 | const { FilePicker } = ChromeUtils.importESModule( 6 | "chrome://zotero/content/modules/filePicker.mjs", 7 | ); 8 | 9 | export async function generatePrismaFromTemplate(prismaData) { 10 | const PizZip = await require("pizzip"); 11 | const PizZipUtils = await require("pizzip/utils"); 12 | const Docxtemplater = await require("docxtemplater"); 13 | 14 | return new Promise((resolve, reject) => { 15 | const typeDocx = 16 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; 17 | 18 | // Template path 19 | const templatePath = getPluginInfo().rootURI + "/assets/template.docx"; 20 | PizZipUtils.getBinaryContent( 21 | templatePath, 22 | async (error: any, content: any) => { 23 | const zip = new PizZip(content); 24 | const doc = new Docxtemplater(zip, { 25 | paragraphLoop: true, 26 | linebreaks: true, 27 | }); 28 | doc.render(prismaData); 29 | 30 | const blob = doc.getZip().generate({ 31 | type: "blob", 32 | mimeType: typeDocx, 33 | // compression: DEFLATE adds a compression step. 34 | // For a 50MB output document, expect 500ms additional CPU time 35 | compression: "DEFLATE", 36 | }); 37 | 38 | // Show filepicker dialog to ask the user where to save it 39 | const outputPath = await showFilePicker( 40 | typeDocx, 41 | FilePicker.filterAll, 42 | "prisma", 43 | ); 44 | if (!outputPath) reject(false); 45 | 46 | const outputFile = Zotero.File.pathToFile(outputPath || ""); 47 | const file = new File([blob], "prisma.docx", { 48 | type: typeDocx, 49 | }); 50 | Zotero.File.putContentsAsync(outputFile, file); 51 | resolve(true); 52 | }, 53 | ); 54 | }); 55 | } 56 | 57 | // export function getPrismaSectionFromName(name: string) { 58 | // const section = prismaSections.filter((obj) => obj.name == name); 59 | // return section[0]; 60 | // } 61 | 62 | // export function countItemsWithStatusName(name: string, items: Zotero.Item[]) { 63 | // const section = getPrismaSectionFromName(name); 64 | // // log(section); 65 | // return countItemsWithTag(section.tag, items); 66 | // } 67 | 68 | // export function getItemsWithTag(items: Zotero.Item[], tag: string) { 69 | // const itemsWithTag = []; 70 | // for (const item of items) { 71 | // if (item.hasTag(tag)) { 72 | // itemsWithTag.push(item); 73 | // } 74 | // } 75 | // return itemsWithTag; 76 | // } 77 | 78 | // function isOtherReason(reason: string) { 79 | // return !prismaSections.find((obj) => { 80 | // return obj.tag == reasonTagPrefix + reason; 81 | // }); 82 | // } 83 | 84 | // export function getPRISMASectionFromItem(item: Zotero.Item) { 85 | // const section = prismaSections.find((obj) => item.hasTag(obj.tag)); 86 | // return prismaSections.find((obj) => item.hasTag(obj.tag)) || []; 87 | // } 88 | 89 | // export function getPrismaData(items: Zotero.Item[]) { 90 | // // log("Fn: getPrismaData") 91 | 92 | // // Get excluded items 93 | // const exclusionTag = allStatuses.filter((obj) => obj.name == "excluded")[0] 94 | // .tag; 95 | // const excludedItems = getItemsWithTag(items, exclusionTag); 96 | 97 | // const idTotal = items.length; 98 | // const idDuplicates = countItemsWithStatusName( 99 | // "identification:duplicated", 100 | // excludedItems, 101 | // ); 102 | // const idAutomation = countItemsWithStatusName( 103 | // "identification:automation", 104 | // excludedItems, 105 | // ); 106 | // const idOther = countItemsWithStatusName( 107 | // "identification:other", 108 | // excludedItems, 109 | // ); 110 | 111 | // const scTotal = idTotal - idDuplicates - idAutomation - idOther; 112 | // const scScExcluded = countItemsWithStatusName( 113 | // "screening:screen:excluded", 114 | // excludedItems, 115 | // ); 116 | 117 | // const scRetTotal = scTotal - scScExcluded; 118 | // const scRetExcluded = countItemsWithStatusName( 119 | // "screening:retrieval:excluded", 120 | // excludedItems, 121 | // ); 122 | 123 | // const scElTotal = scRetTotal - scRetExcluded; 124 | 125 | // // Get the label and count for all the other reasons 126 | // let otherTotal = 0; 127 | // const otherReasons: { label: string; records: number }[] = []; 128 | // for (const item of excludedItems) { 129 | // let reason = getReasonFromItem(item); 130 | // if (isOtherReason(reason)) { 131 | // // If no reason provided, use the default label 132 | // if (!reason) reason = getString("report-reason-default-label"); 133 | 134 | // // If reason is already created, increment 135 | // const currentReasonInArray = otherReasons.filter( 136 | // (obj) => obj.label == reason, 137 | // )[0]; 138 | // if (currentReasonInArray) { 139 | // currentReasonInArray.records += 1; 140 | 141 | // // If new reason, add it to the array 142 | // } else { 143 | // const currentReason = { 144 | // label: reason, 145 | // records: 1, 146 | // }; 147 | // otherReasons.push(currentReason); 148 | // } 149 | 150 | // // Increment the total 151 | // otherTotal++; 152 | // } 153 | // } 154 | 155 | // const incTotal = scElTotal - otherTotal; 156 | 157 | // const prismaData: PRISMAData = { 158 | // identification: { 159 | // collection: { 160 | // databases: 0, 161 | // registers: idTotal, 162 | // other: idOther, 163 | // }, 164 | // excluded: { 165 | // duplicates: idDuplicates, 166 | // automation: idAutomation, 167 | // other: idOther, 168 | // }, 169 | // }, 170 | // screening: { 171 | // screen: { 172 | // total: scTotal, 173 | // excluded: scScExcluded, 174 | // }, 175 | // retrieval: { 176 | // total: scRetTotal, 177 | // excluded: scRetExcluded, 178 | // }, 179 | // eligibility: { 180 | // total: scElTotal, 181 | // reasons: otherReasons, 182 | // }, 183 | // }, 184 | // included: { 185 | // total: incTotal, 186 | // records: { 187 | // studies: 0, 188 | // reports: 0, 189 | // }, 190 | // }, 191 | // }; 192 | 193 | // return prismaData; 194 | // } 195 | 196 | // export function getPRISMAEligibilityOtherReasons(items: Zotero.Item[]) { 197 | // const reasons = []; 198 | // for (const item of items) { 199 | // const reason = getPRISMAEligibilityOtherReason(item); 200 | // if (reason) { 201 | // reasons.push(reason); 202 | // } 203 | // } 204 | 205 | // return reasons; 206 | // } 207 | // export function getPRISMAEligibilityOtherReason(item: Zotero.Item) { 208 | // const tags = item.getTags(); 209 | // const reasonTags = tags.filter((obj) => { 210 | // return prismaEligibilityReasonTagPrefix.includes(obj.tag); 211 | // }); 212 | // // log(tags, reasonTags); 213 | // return reasonTags[0] || undefined; 214 | // } 215 | 216 | // type PRISMAData = { 217 | // identification: { 218 | // collection: { 219 | // databases: number; 220 | // registers: number; 221 | // other: number; 222 | // }; 223 | // excluded: { 224 | // duplicates: number; 225 | // automation: number; 226 | // other: number; 227 | // }; 228 | // }; 229 | // screening: { 230 | // screen: { 231 | // total: number; 232 | // excluded: number; 233 | // }; 234 | // retrieval: { 235 | // total: number; 236 | // excluded: number; 237 | // }; 238 | // eligibility: { 239 | // total: number; 240 | // reasons: { 241 | // label: string; 242 | // records: number; 243 | // }[]; 244 | // }; 245 | // }; 246 | // included: { 247 | // total: number; 248 | // records: { 249 | // studies: number; 250 | // reports: number; 251 | // }; 252 | // }; 253 | // }; -------------------------------------------------------------------------------- /src/views/preferencesView.tsx: -------------------------------------------------------------------------------- 1 | // External Dependencies 2 | import { useRef, useState } from "react"; 3 | import { Controller, useForm } from "react-hook-form"; 4 | 5 | // Internal Dependencies 6 | import defaultPreferences from "../utils/prefs.default" 7 | import Table from "../components/table"; 8 | import Modal from "../components/modal"; 9 | // import KeystrokeInput from "../components/keystrokeInput/keystrokeInput"; 10 | import KeystrokeInput from "keystroke-input"; 11 | import { usePref } from "../utils/hooks" 12 | import { getKeyCombination, getStringFromKeyCombination, JSONFromString } from "../utils/utils"; 13 | 14 | // Types 15 | import { usePrefStateFunction } from "../types/types"; 16 | import { ArticleStatus } from "../types/addon"; 17 | 18 | // Devtools 19 | import { log } from "../utils/devtools"; 20 | 21 | function PreferencePane() { 22 | 23 | Zotero.log("Rendering PreferencePane") 24 | 25 | // Prefs 26 | const [statusList, setStatusList] = usePref("statusList", defaultPreferences.statusList, { parseJSON: true }) as [ArticleStatus[], usePrefStateFunction] 27 | 28 | // Table consts 29 | let data = [...statusList as ArticleStatus[]] 30 | const columns = [ 31 | { name: 'label', label: 'Label' }, 32 | { name: 'tag', label: 'Tag' }, 33 | { name: 'color', label: 'Color' }, 34 | { name: 'invertTextColor', label: 'Invert Text Color', labelFn: (status: ArticleStatus) => status.invertTextColor ? "✓" : "" }, 35 | // { name: 'keystroke', label: 'Keystroke', labelFn: (status: ArticleStatus) => (status.keystroke != null ? getStringFromKeyCombination(JSON.parse(String(status.keystroke))) : "") }, 36 | { name: 'keystroke', label: 'Keystroke', labelFn: (status: ArticleStatus) => (status.keystroke != null ? getStringFromKeyCombination(status.keystroke) : "") }, 37 | ]; 38 | 39 | // States 40 | const [modalOpen, setModalOpen] = useState(false) 41 | const [isEditing, setIsEditing] = useState(false) 42 | 43 | // Refs 44 | const formColorRef = useRef(null) 45 | 46 | // Form 47 | interface FormData { 48 | index: number; 49 | label: string; 50 | tag: string; 51 | color: string; 52 | invertTextColor: boolean; 53 | keystroke: string; 54 | } 55 | 56 | const { register, control, handleSubmit, setValue } = useForm() 57 | const onSubmit = ({ index, ...formData }: { index: number, label: string, tag: string, color: string, invertTextColor: boolean, keystroke: string }) => { 58 | 59 | const processedFormData = { ...formData, keystroke: JSONFromString(String(formData.keystroke)) } 60 | 61 | const newStatusList = [...statusList] 62 | const isNew = index === -1 63 | if (isNew) { 64 | newStatusList.push(processedFormData) 65 | } else { 66 | newStatusList[index] = processedFormData 67 | } 68 | 69 | // Save the new pref 70 | setStatusList(newStatusList) 71 | 72 | // Close the modal 73 | setModalOpen(false) 74 | setIsEditing(false) 75 | } 76 | 77 | // Events 78 | const doubleClickHandler = (item: ArticleStatus, index: number) => { 79 | 80 | if (item) { 81 | setValue("index", index) 82 | setValue("label", item.label) 83 | setValue("invertTextColor", item.invertTextColor) 84 | // setValue("keystroke", item.keystroke) 85 | const payload = { 86 | altKey: item.keystroke?.altKey, 87 | ctrlKey: item.keystroke?.ctrlKey, 88 | metaKey: item.keystroke?.metaKey, 89 | shiftKey: item.keystroke?.shiftKey, 90 | code: item.keystroke?.code, 91 | } 92 | setValue("keystroke", JSON.stringify(payload)) 93 | // console.log("KEYSTROKE: ", item.keystroke) 94 | // setValue("keystroke", { "altKey": true, "ctrlKey": false, "metaKey": false, "shiftKey": false, "code": "KeyU" }) 95 | // setValue("keystroke", { "altKey": true, "ctrlKey": false, "metaKey": false, "shiftKey": false, "code": "KeyU" }) 96 | setValue("color", item.color) 97 | setValue("tag", item.tag) 98 | } 99 | // Update states 100 | setModalOpen(true) 101 | setIsEditing(true) 102 | } 103 | // setValue("keystroke", { "altKey": true, "ctrlKey": false, "metaKey": false, "shiftKey": false, "code": "KeyU" }) 104 | return ( 105 | <> 106 |
107 |
108 | 109 | 110 |
111 | 120 | 123 | 124 |
125 | 126 |
127 |
Additional Shortcuts:
128 |
{getStringFromKeyCombination(getKeyCombination("Alt S"))} - Open Review Info Panel
129 |
{getStringFromKeyCombination(getKeyCombination("Alt D"))} - Open Generate PRISMA Diagram Panel
130 |
131 | 132 |
133 | Restart Zotero to apply changes 134 |
135 | 136 | 137 | { log("open") }} onClose={() => { 138 | setIsEditing(false) 139 | log("close") 140 | }}> 141 |
142 | 143 |
144 | 145 | {/* Hidden Inputs */} 146 | 147 | 148 |
149 |
150 | 151 | 152 |
153 | 154 |
155 | 156 | 157 |
158 |
159 | 160 |
161 |
162 | 163 | ( 167 | <> 168 | 169 | 170 | {/* formColorRef.current.value = color.hex} 173 | /> */} 174 | 175 | )} 176 | /> 177 |
178 | 179 |
180 | 181 | 182 |
183 |
184 | 185 |
186 | 187 | 188 | { 192 | console.log(field) 193 | return ( 194 | 198 | ) 199 | }} 200 | /> 201 |
202 | 203 |
204 | 205 | 206 |
207 | 208 |
209 |
210 | 211 | 212 | ) 213 | } 214 | 215 | export default PreferencePane -------------------------------------------------------------------------------- /src/views/statusView.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form" 2 | 3 | // Define the form data structure 4 | interface FormData { 5 | status: string; 6 | prisma: string; 7 | comments: string; 8 | } 9 | import Modal, { ModalComponent } from "../components/modal" 10 | import { getPRISMALabel, setItemComments, setItemPRISMACategory, setItemStatus } from "../utils/columns.utils" 11 | import { useKeyboardShortcut, usePref } from "../utils/hooks" 12 | import { PropsWithChildren, ReactElement, Ref, useImperativeHandle, useRef, useState } from "react" 13 | import { KeyCombination, usePrefStateFunction } from "../types/types" 14 | import { getKeyCombination, getMatchingTag, getStatus, getTagsFromCurrentLibrary, mergeRefs } from "../utils/utils"; 15 | import { useAutocomplete } from "../components/hooks/autocomplete"; 16 | import defaultPreferences from "../utils/prefs.default"; 17 | import { ArticleStatus, PRISMACategory } from "../types/addon"; 18 | import { ArrowDown, ChevronDown, CircleChevronDownIcon } from "lucide-react"; 19 | import { log } from "../utils/devtools"; 20 | 21 | export interface StatusViewComponent { 22 | edit: () => void 23 | } 24 | export default function StatusView({ ref }: { ref: Ref }): ReactElement & StatusViewComponent { 25 | 26 | // console.log("STATUS") 27 | 28 | // Prefs 29 | const [statusList,] = usePref("statusList", defaultPreferences.statusList, { parseJSON: true, observe: true }) as [ArticleStatus[], usePrefStateFunction] 30 | const [commentsTagPrefix,] = usePref("commentsTagPrefix", defaultPreferences.commentsTagPrefix, { observe: true }) as [string, usePrefStateFunction] 31 | const [prismaCategories,] = usePref("prismaCategories", defaultPreferences.prismaCategories, { parseJSON: true, observe: true }) as [PRISMACategory[], usePrefStateFunction] 32 | const [editStatusShortcut,] = usePref("editStatusShortcut", defaultPreferences.editStatusShortcut, { parseJSON: true, observe: true }) as [KeyCombination, usePrefStateFunction] 33 | 34 | // States 35 | const [isCommentsModalOpen, setIsCommentsModalOpen] = useState(false) 36 | const [commentList, setCommentList] = useState([]) 37 | const [articles, setArticles] = useState([]) 38 | 39 | // Refs 40 | const inputRef = useRef(null) 41 | 42 | // Form 43 | const { register, control, handleSubmit, setValue, setFocus } = useForm() 44 | 45 | // Events 46 | // -- Submit 47 | const onSubmit = ({ status, prisma, comments }: FormData) => { 48 | 49 | setItemStatus(statusList, statusList.find(stat => stat.tag == status) as ArticleStatus) 50 | setItemPRISMACategory(prismaCategories, prismaCategories.find(cat => cat.tag == prisma) as PRISMACategory) 51 | setItemComments(commentsTagPrefix, comments) 52 | 53 | // Close the modal 54 | modalRef.current?.close() 55 | // setIsCommentsModalOpen(false) 56 | 57 | // Zotero.ItemTreeManager.refreshColumns() 58 | } 59 | 60 | // Imperative Functions 61 | // -- Trigger Edit 62 | const triggerEdit = async () => { 63 | const selectedArticles = ZoteroPane.getSelectedItems() 64 | 65 | // Get all comments for autocompletion 66 | try { 67 | const allTags = await getTagsFromCurrentLibrary() 68 | const filteredTags = allTags.filter((tag: string) => 69 | tag.startsWith(commentsTagPrefix) 70 | ) 71 | .map(comment => comment.replace(commentsTagPrefix, "")) 72 | setCommentList(filteredTags) 73 | } catch (error) { 74 | console.error("Error getting tags from library:", error) 75 | } 76 | 77 | if (selectedArticles.length > 1) { 78 | setValue("comments", "") 79 | } else if (selectedArticles.length === 1) { 80 | const filteredCommentTags = selectedArticles[0] 81 | .getTags() 82 | .filter(tag => tag.tag.startsWith(commentsTagPrefix)) 83 | 84 | let currentComment = "" 85 | if (filteredCommentTags.length > 0) { 86 | currentComment = filteredCommentTags[0].tag.replace(commentsTagPrefix, "") 87 | } 88 | 89 | setValue("comments", currentComment) 90 | } 91 | setArticles(selectedArticles) 92 | setIsCommentsModalOpen(true) 93 | } 94 | 95 | // Expose the triggerEdit function 96 | useImperativeHandle(ref, () => ({ 97 | ...inputRef.current as HTMLInputElement, 98 | edit: triggerEdit 99 | })) 100 | 101 | // Keyboard Shortcuts 102 | useKeyboardShortcut(editStatusShortcut, () => { 103 | triggerEdit() 104 | }) 105 | 106 | // Input props 107 | const commentProps = register("comments") 108 | 109 | const autocompleteRef = useRef(null) 110 | // Autocomplete 111 | const handleSelection = (suggestion: string) => { 112 | setValue("comments", suggestion) 113 | } 114 | const onFocus = () => { 115 | setFocus("comments") 116 | } 117 | const onBlur = () => { 118 | setTimeout(() => { autocompleteRef.current?.close() }, 100) 119 | } 120 | 121 | const modalRef = useRef(null) 122 | 123 | // Load form 124 | // If single article 125 | if (articles.length == 1) { 126 | // setCurrentStatus(getStatus(articles[0], statusList)) 127 | setValue("status", getMatchingTag(articles[0], statusList.map(stat => stat.tag)) || "") 128 | setValue("status", getStatus(articles[0], statusList)?.tag || "") 129 | setValue("prisma", getMatchingTag(articles[0], prismaCategories.map(cat => cat.tag)) || "") 130 | } 131 | setFocus("comments") 132 | 133 | return <> 134 | {}} 140 | onClose={() => { 141 | if (Zotero.getMainWindow().document.querySelector("#item-tree-main-default")) 142 | (Zotero.getMainWindow().document.querySelector("#item-tree-main-default") as HTMLElement).focus() 143 | }} 144 | > 145 |
148 | 149 |
150 |
151 | {/* Status */} 152 | 153 |
154 | 165 | 166 | 167 | 168 |
169 |
170 | 171 | {/* PRISMA Category */} 172 |
173 | 174 |
175 | 186 | 187 | 188 | 189 |
190 |
191 | 192 | {/* Comments */} 193 |
194 | 195 | 206 |
207 |
208 | 209 |
210 | 211 | 212 |
213 | 214 |
215 | 216 | } 217 | 218 | interface AutocompleteProps extends React.InputHTMLAttributes { 219 | ref?: Ref 220 | autocompleteList: string[] 221 | handleSelection: (suggestion: string) => void 222 | inputRef?: Ref 223 | } 224 | export interface AutocompleteComponent { 225 | close: () => void 226 | } 227 | function Autocomplete({ ref, autocompleteList, handleSelection, inputRef, ...props }: AutocompleteProps): ReactElement & AutocompleteComponent { 228 | 229 | log("List", autocompleteList) 230 | // Hooks 231 | const { suggestions, selectedIndex, isOpen, setIsOpen, onKeyDown, onKeyUp } = useAutocomplete(autocompleteList, handleSelection) 232 | 233 | // Events 234 | const open = () => { setIsOpen(true) } 235 | const close = () => { setIsOpen(false) } 236 | 237 | // Export 238 | useImperativeHandle(ref, () => ({ 239 | close: close 240 | })) 241 | return <> 242 | 249 |
0 ? "" : "hidden"}`}> 250 | {suggestions.map((suggestion, index) => ( 251 |
{ console.log("SELECT", suggestion); handleSelection(suggestion) }}> 252 | {suggestion} 253 |
254 | ))} 255 |
256 | 257 | } -------------------------------------------------------------------------------- /src/views/prismaView.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, forwardRef, ReactElement, Ref, useImperativeHandle, useRef, useState } from "react"; 2 | import Modal, { ModalComponent } from "../components/modal"; 3 | import { useKeyboardShortcut, usePref } from "../utils/hooks"; 4 | import defaultPreferences from "../utils/prefs.default"; 5 | import { KeyCombination, usePrefStateFunction } from "../types/types"; 6 | import { useForm } from "react-hook-form"; 7 | import { generatePrismaFromTemplate } from "../utils/prisma"; 8 | import { PRISMACategory } from "../types/addon"; 9 | import { countArticlesWithTag, getComments, mergeRefs } from "../utils/utils"; 10 | 11 | // Types 12 | type PRISMAFormData = { 13 | databases: number, 14 | registers: number, 15 | studies: number, 16 | reports: number, 17 | useOtherReasons: boolean, 18 | } 19 | type PRISMAOtherReason = { 20 | label: string, 21 | records: number, 22 | } 23 | export interface PRISMAViewComponent { 24 | open: () => void 25 | } 26 | 27 | // React Component 28 | const PRISMAView = forwardRef((_, ref) => { 29 | 30 | // Prefs 31 | const [prismaCategories,] = usePref("prismaCategories", defaultPreferences.prismaCategories, { parseJSON: true, observe: true }) as [PRISMACategory[], usePrefStateFunction] 32 | const [generatePRISMAShortcut,] = usePref("generatePRISMAShortcut", defaultPreferences.generatePRISMAShortcut, { parseJSON: true, observe: true }) as [KeyCombination, usePrefStateFunction] 33 | const [commentsTagPrefix,] = usePref("commentsTagPrefix", defaultPreferences.commentsTagPrefix, { observe: true }) as [string, usePrefStateFunction] 34 | 35 | // Refs 36 | const modalRef = useRef(null) 37 | const studiesRef = useRef(null) 38 | const reportsRef = useRef(null) 39 | 40 | // Status 41 | const [modalOpen, setModalOpen] = useState(false) 42 | 43 | // Form 44 | const { register, setValue, handleSubmit } = useForm({ 45 | defaultValues: { 46 | useOtherReasons: true 47 | } 48 | }) 49 | 50 | const studiesProps = register("studies", { valueAsNumber: true }) 51 | const reportsProps = register("reports", { valueAsNumber: true }) 52 | 53 | // Get articles 54 | const selectedArticles = ZoteroPane.getSelectedItems() 55 | const totalArticles = selectedArticles.length 56 | // const studyIncludedCategory = prismaCategories.find(cat => cat.name == "included:studies") 57 | // const reportIncludedCategory = prismaCategories.find(cat => cat.name == "included:reports") 58 | // console.log(studyIncludedCategory, reportIncludedCategory) 59 | // const includedStudies = selectedArticles.filter(art => art.hasTag(studyIncludedCategory.tag) || art.hasTag(reportIncludedCategory.tag)) 60 | // const includedReports = selectedArticles.filter(art => art.hasTag(studyIncludedCategory.tag) || art.hasTag(reportIncludedCategory.tag)) 61 | // console.log(studyIncludedCategory, reportIncludedCategory) 62 | 63 | // Events 64 | const open = () => { 65 | setModalOpen(true) 66 | } 67 | 68 | const onSubmit = (formData: PRISMAFormData) => { 69 | 70 | // Get data from the deifned PRISMA categories 71 | let prismaData: { [key: string]: any } = { 72 | ...formData, 73 | totalArticles: totalArticles, 74 | ...Object.fromEntries(prismaCategories.map(cat => [cat.name, countArticlesWithTag(selectedArticles, cat.tag)])) 75 | } 76 | 77 | console.log(prismaData) 78 | 79 | // Get eligibility exclusion reasons 80 | const eligibilityExclusionTag = prismaCategories.filter(cat => cat.allowCustomReason) 81 | 82 | eligibilityExclusionTag.map(cat => { 83 | let otherReasons: PRISMAOtherReason[] = [] 84 | selectedArticles.map(article => { 85 | 86 | // If it doesn't have the tag, continue 87 | if (!article.hasTag(cat.tag)) return; 88 | 89 | // Get and return comments 90 | const commentsTags = getComments(commentsTagPrefix, article) 91 | if (commentsTags.length == 0) return; 92 | 93 | // Extract the comments 94 | const comments = commentsTags[0].replace(commentsTagPrefix, "") 95 | 96 | // See if the reason already exists 97 | const index = otherReasons.findIndex(reason => reason.label == comments) 98 | // If reason exists 99 | if (index > -1) { 100 | // Increment the other reasons count 101 | otherReasons[index].records = otherReasons[index].records + 1 102 | } else { 103 | const newReason: PRISMAOtherReason = { label: comments, records: 1 } 104 | otherReasons.push(newReason) 105 | } 106 | }) 107 | // @ts-ignore 108 | prismaData[cat.name] = otherReasons 109 | }) 110 | 111 | // Calculate the included records in each subsection 112 | if (formData.useOtherReasons) { 113 | try { 114 | prismaData["screening:screen"] = totalArticles - prismaData["identification:duplicated"] - prismaData["identification:automation"] - prismaData["identification:other"] 115 | prismaData["screening:retrieval"] = prismaData["screening:screen"] - prismaData["screening:screen:excluded"] 116 | prismaData["screening:eligibility"] = prismaData["screening:retrieval"] - prismaData["screening:retrieval:excluded"] 117 | } catch (error) { 118 | console.error(error) 119 | } 120 | } 121 | 122 | generatePrismaFromTemplate(prismaData) 123 | } 124 | 125 | const onChangeCounts = (event: ChangeEvent, onChangeFunction: (event: ChangeEvent) => void) => { 126 | const field = event.currentTarget.getAttribute('name') 127 | if (field == "studies") { 128 | const reports = totalArticles - parseInt(studiesRef.current?.value || "0") 129 | setValue("reports", reports > 0 ? reports : 0) 130 | } else { 131 | const studies = totalArticles - parseInt(reportsRef.current?.value || "0") 132 | setValue("studies", studies > 0 ? studies : 0) 133 | } 134 | onChangeFunction(event) 135 | } 136 | 137 | // Keyboard Shortcuts 138 | useKeyboardShortcut(generatePRISMAShortcut, () => { 139 | open() 140 | }) 141 | 142 | // Export functions 143 | useImperativeHandle(ref, () => ({ 144 | open: open 145 | })) 146 | 147 | return ( 148 | 153 |
154 |
155 |

Identification

156 |
157 |
158 | 161 | 167 |
168 |
169 | 172 | 177 |
178 |
179 |

Inclusion

180 |
181 | Total of Studies and Reports: 182 | {totalArticles} 183 | 184 | {/* Included: 185 | {totalArticles} */} 186 |
187 | {/*
188 |
189 | 192 | { onChangeCounts(event, studiesProps.onChange) }} 197 | className="input" 198 | type="text" 199 | /> 200 |
201 |
202 | 205 | { onChangeCounts(event, reportsProps.onChange) }} 210 | className="input" 211 | /> 212 |
213 |
*/} 214 | 228 |
229 | 230 | 231 | 232 |
233 |
234 |
235 | References
236 | 237 | From: Page MJ, McKenzie JE, Bossuyt PM, Boutron I, Hoffmann TC, 238 | Mulrow CD, et al. The PRISMA 2020 statement: an updated 239 | guideline for reporting systematic reviews. BMJ 2021;372:n71. 240 | doi: 10.1136/bmj.n71 241 | 242 |
243 | 244 |
245 |
246 | ) 247 | }) 248 | export default PRISMAView --------------------------------------------------------------------------------