This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
.
================================================
FILE: README.md
================================================

# 茉莉花 Jasminum
[](https://www.zotero.org) [](https://github.com/windingwind/zotero-plugin-template) 
简体中文 | [English](doc/README-en.md)
## 1. 基础功能
- 中文PDF元数据抓取
- 中文转换器下载,转换器来源于 Zotero中文社区 [translators_CN](https://github.com/l0o0/translators_CN)
- 中文引用格式下载,引用格式来源于项目 Zotero中文社区 [styles](https://github.com/zotero-chinese/styles)
- 小工具
- 语言设置
- 中文姓名拆分与合并
## 2.使用教程
### 2.1 元数据抓取
目前支持仅支持从**中国知网**获取元数据,后续考虑会添加其他数据来源。
在 Zotero 中添加中文附件后,右键附件,在菜单栏选择`茉莉花抓取` -> `抓取期刊元数据`,在弹出窗口可以看到元数据抓取的结果。
如果有多个搜索结果,需要你手动选择最匹配的结果,再点击确认,即可完成抓取。

### 2.3 本地附件匹配功能
在使用 Zotero Connector 在浏览器上抓取中文期刊时(尤其是中国知网),经常出现元数据抓取成功而附件无法下载自动的异常,当你手动下载期刊附件(PDF/CAJ)后,可以方便地用此功能来将下载的附件与元数据匹配。
右键期刊条目,`小工具` -> `在下载文件夹中查找附件`,该功能会自动在当前`下载目录`中寻找与当前条目匹配的附件,匹配规划是**根据期刊标题与文件名的匹配度**。
`下载目录`默认是系统的下载目录,Windows系统默认是`C:\Users\用户名\Downloads`,Mac系统默认是`/Users/用户名/Downloads`,Linux系统默认是`/home/用户名/Downloads`。也可以在`设置`中修改下载目录。
下载目录中匹配成功的附件默认会移动到备份目录中`下载目录/jasminum-backup`中,在设置中还可以选择
- 删除匹配成功的附件。匹配到元数据的附件已经保存到Zotero中,可以放心删除下载目录中的附件(个人建议删除,避免下载目录中附件过多)。
- 无须处理。即使匹配成功,附件还是会在下载目录中,当然Zotero已经保存了一份。
### 2.3 PDF大纲
在 PDF 阅读窗口的左侧边栏中,点击茉莉花书签按钮,即可看到书签大纲窗口。

最上方的5个按钮,功能分别是:
- 展开所有书签
- 折叠所有书签
- 添加书签
- 删除书签
- 将书签内容保存到PDF(默认只以配置文件的形式保存到本地)
**键盘快捷键导航**
- 键盘↑,上一个书签(跳过折叠内容)
- 键盘↓,下一个书签(跳过折叠内容)
- 键盘←或→,展开或折叠节点
- 空格键,编辑书签内容
- [,将书签移到上一级(作为原上级节点的下一个相邻节点)
- ],将书签移到下一级(自动将相邻的上一个节点作为上级节点)
- \,创建新节点(默认作为选中节点的子节点)
- Delete 或 Backspace,删除节点
## 3. ❤️致谢
特别感谢 [jiaojiaodubai](https://github.com/jiaojiaodubai) 同学,长期以来对 [translators_CN](https://github.com/l0o0/translators_CN) 和 本项目 的贡献。
================================================
FILE: addon/bootstrap.js
================================================
/* eslint-disable no-undef */
/**
* Most of this code is from Zotero team's official Make It Red example[1]
* or the Zotero 7 documentation[2].
* [1] https://github.com/zotero/make-it-red
* [2] https://www.zotero.org/support/dev/zotero_7_for_developers
*/
var chromeHandle;
function install(data, reason) {}
async function startup({ id, version, resourceURI, rootURI }, reason) {
await Zotero.initializationPromise;
// String 'rootURI' introduced in Zotero 7
if (!rootURI) {
rootURI = resourceURI.spec;
}
var aomStartup = Components.classes[
"@mozilla.org/addons/addon-manager-startup;1"
].getService(Components.interfaces.amIAddonManagerStartup);
var manifestURI = Services.io.newURI(rootURI + "manifest.json");
chromeHandle = aomStartup.registerChrome(manifestURI, [
["content", "__addonRef__", rootURI + "chrome/content/"],
]);
/**
* Global variables for plugin code.
* The `_globalThis` is the global root variable of the plugin sandbox environment
* and all child variables assigned to it is globally accessible.
* See `src/index.ts` for details.
*/
const ctx = {
rootURI,
};
ctx._globalThis = ctx;
Services.scriptloader.loadSubScript(
`${rootURI}/chrome/content/scripts/__addonRef__.js`,
ctx,
);
Zotero.__addonInstance__.hooks.onStartup();
}
async function onMainWindowLoad({ window }, reason) {
Zotero.__addonInstance__?.hooks.onMainWindowLoad(window);
}
async function onMainWindowUnload({ window }, reason) {
Zotero.__addonInstance__?.hooks.onMainWindowUnload(window);
}
function shutdown({ id, version, resourceURI, rootURI }, reason) {
if (reason === APP_SHUTDOWN) {
return;
}
if (typeof Zotero === "undefined") {
Zotero = Components.classes["@zotero.org/Zotero;1"].getService(
Components.interfaces.nsISupports,
).wrappedJSObject;
}
Zotero.__addonInstance__?.hooks.onShutdown();
Cc["@mozilla.org/intl/stringbundle;1"]
.getService(Components.interfaces.nsIStringBundleService)
.flushBundles();
Cu.unload(`${rootURI}/chrome/content/scripts/__addonRef__.js`);
if (chromeHandle) {
chromeHandle.destruct();
chromeHandle = null;
}
}
function uninstall(data, reason) {}
================================================
FILE: addon/chrome/content/preferences-main.xhtml
================================================
click
click
================================================
FILE: addon/chrome/content/preferences-translators.xhtml
================================================
================================================
FILE: addon/chrome/content/prefpanel.css
================================================
:root {
--dropdown-bg-color: #f9f9f9;
--dropdown-text-color: #333;
--dropdown-border-color: #ccc;
--dropdown-shadow-color: rgba(0, 0, 0, 0.2);
}
@media (prefers-color-scheme: dark) {
:root {
--dropdown-bg-color: #333;
--dropdown-text-color: #f9f9f9;
--dropdown-border-color: #555;
--dropdown-shadow-color: rgba(255, 255, 255, 0.2);
}
}
.dropdown-content {
display: none;
position: absolute;
background-color: var(--dropdown-bg-color);
border: 1px solid var(--dropdown-border-color);
box-shadow: 0px 8px 16px var(--dropdown-shadow-color);
z-index: 1;
padding: 8px 16px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
color: var(--dropdown-text-color);
}
.dropdown-content label {
display: block;
margin: 5px 0;
cursor: pointer;
color: var(--dropdown-text-color);
}
.show {
display: block;
}
.hidden {
display: none;
}
.help-icon {
margin-top: 5px;
margin-bottom: 5px;
width: 20px;
}
================================================
FILE: addon/chrome/content/progress.xhtml
================================================
Close
OK
================================================
FILE: addon/locale/en-US/addon.ftl
================================================
plugin-name = Jasminum
prefs-table-title = Title
prefs-table-detail = Detail
tabpanel-lib-tab-label = Lib Tab
tabpanel-reader-tab-label = Reader Tab
# Preference
select-download-folder = Select download folder
get-Chinese-styles = Get Chinese Styles
info-translators-cn-updaing = Chinese translators are under updating.
info-best-speed-source-updated = Updated to fastest source: { $source }
info-best-speed-source-failed = Failed to select fastest source, please check network connection
# Preference translator table
th-filename = File name
th-label = Label
th-local-update-time = Local update time
th-remote-update-time = Remote update time
# Help menu
help-menu-chinese = Zotero Chinese Community
help-menu-wiki = Zotero Wiki
help-menu-addons = Addon Store
help-menu-csl = Donwload more CSL
help-menu-translator = Help with Chinese literature capture
# Menu
menu-metadata = Metadata(CN)
menuitem-retrieveMetadata = Find article metadata
menuitem-retrieveMetadataForBook = Find book metadata
menuitem-find-attachment = Find attachment in Folder
menuitem-import-attachments = Import attachments from Folder
menu-tools = Tools
menuitem-mergeName = Concat Name
menuitem-splitName = Split Name
menuitem-updateCNKICite = Update CNKI citation
# ui
CNKIcitation = CNKICite
# popup window
citation = Cite
no-chinese-item-for-citation = Only Chinese items can find CNKI citation
update-translators-start = Start updating translators
update-successfully = Update successfully: { $name }
update-failed = Update failed: { $name }
update-skipped = Up to date: { $name }
update-translators-complete = Update translators completed, Success: { $successCounts }, Failed: { $failCounts }, Up to date: { $skipCounts }
no-item-need-attachment = No item need attachment
no-attachments-found = No attachments found (PDF, CAJ, etc.)
import-attachments-success = Import attachments from folder successfully
importing-attachments-is-running = An attachment import task is already running. Please try again later.
# outline
outline = Show Bookmark (By Jasminum)
outline-expand-all = Expand all
outline-collapse-all = Collapse all
outline-add = Add bookmark
outline-delete = Delete bookmark
outline-save-to-pdf = Save outline to PDF
outline-empty-prompt = Please click the button above { $icon } to create a bookmark
outline-delete-confirm = This node has child nodes, do you want to delete?
{" "}
If you delete, all child nodes will be deleted.
# bookmark
bookmark = Show Bookmarks (By Jasminum)
bookmark-add = Add bookmark
bookmark-delete = Delete bookmark
# Progress window
task-msg-header = If you need help with capture issues, please screenshot the following content and contact the developer: [RedBook l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)
task-already-exists = Task already exists: { $title }
================================================
FILE: addon/locale/en-US/mainWindow.ftl
================================================
item-section-example1-head-text =
.label = Plugin Template: Item Info
item-section-example1-sidenav-tooltip =
.tooltiptext = This is Plugin Template section (item info)
item-section-example2-head-text =
.label = Plugin Template: Reader [{$status}]
item-section-example2-sidenav-tooltip =
.tooltiptext = This is Plugin Template section (reader)
item-section-example2-button-tooltip =
.tooltiptext = Unregister this section
================================================
FILE: addon/locale/en-US/preferences-main.ftl
================================================
# Metadata Settings
pref-group-metadata = Chinese Metadata Retrieval Settings
label-isMainlandChina =
.label = Currently located in Chinese Mainland (excluding Hong Kong, Macao and Taiwan), uncheck for overseas users
label-autoupdate-metadata =
.label = Automatically retrieve metadata from CNKI when adding Chinese PDF/CAJ files
label-rename =
.label = Rename attachments based on metadata (requires Attanger or zotmoov plugin)
label-namepattern = Filename Parsing Template
label-namepattern-auto =
.label = Smart Recognition
.tooltiptext = Use Jasmine's built-in algorithm to intelligently identify authors or titles from filenames
label-namepattern-tg =
.label = Title_Author (Default)
.tooltiptext = Rename files in the format "Title_FirstAuthor," e.g., "Design and Application of Redundant Avionics Systems for Drones_Yang Lu.caj"
label-namepattern-t =
.label = Title
.tooltiptext = Rename files using the "Title" format, e.g., "Design and Application of Redundant Avionics Systems for Drones.caj"
label-namepattern-info = Filename recognition template. Select a format from the dropdown or enter directly
label-namepattern-custom =
.label = Custom
.tooltiptext = Set custom rules to extract title and author information from filenames for metadata retrieval
label-choose-namepattern =
.label = Select Template
label-metadata-source = Metadata Retrieval Source
label-choose-source =
.label = Select Data Source
label-metadata-source-cnki =
.label = CNKI (China National Knowledge Infrastructure)
label-metadata-source-cvip =
.label = VIP Journals (Chinese VIP Information)
label-pdf-match-folder = Attachment Matching Folder
label-choose-folder =
.label = Select Folder
namepattern-desc =
.tooltiptext = Retrieve CNKI metadata based on filenames. Filename format settings: {"{"}%t{"}"}=Title, {"{"}%g{"}"}=Author, {"{"}%y{"}"}=Year, {"{"}%j{"}"}=Other (e.g., source information); specify separators as needed; multiple separators can be used consecutively; file extensions are ignored. Default uses {"{"}%t{"}"}_{"{"}%g{"}"}, which recognizes most CNKI filename formats, including filenames with only titles and no separators.
# Transator Settings
pref-group-translators = Chinese Translator Settings
label-translator-source = Translator Download Source
label-best-speed = Choose Fastest Source
translatorSource-desc =
.tooltiptext = Select the translator download source. Generally, there is no need to switch. If you cannot download the Chinese translator, you can try other sources or click the "Choose Fastest Source" button.
label-auto-update-translators =
.label = Automatically Update Translators
label-translators-force-update =
.label = Update Immediately
label-translators-detail = Translator Details
label-translators-detail-click = Click to View
# Attachment Settings
pref-group-attachment = Local Attachment Search Settings
attachment-folder-desc =
.tooltiptext = Search for attachments in the download directory and match them to entries that are missing attachments.
Set this to your browser's download directory, and the plugin can batch import and search for attachments from the download directory.
label-pdf-match-folder = Attachment Download Folder
action-after-import = After matching attachments to entries, what to do with the original downloaded files:
label-choose-folder =
.label = Choose Folder
nothing-label =
.label = Do Nothing
backup-label =
.label = Backup Attachment
delete-label =
.label = Delete Attachment
action-after-import-desc =
.tooltiptext = After successfully matching attachments to entries, you can choose one of the following actions:
1. Do Nothing: No action is taken, and the downloaded files remain in the download directory.
2. Backup Attachment: Back up the original downloaded files to a specified directory.
3. Delete Attachment: Delete the original downloaded files (the attachments are already matched and saved in Zotero).
# Outline Bookmark Settings
pref-group-bookmark = Outline Bookmark Settings
label-disableZoteroOutline =
.label = Disable Zotero's Built-in Outline
label-enableBookmark =
.label = Enable Outline Bookmark
outline-desc =
.tooltiptext = Please note that when you modify the outline or bookmarks, you need to click the 'Save' button to save them to the PDF file. By default, bookmark and outline information is saved separately from the PDF file.
# Tool Settings
pref-group-tools = Tool Settings
label-auto-split-name =
.label = Automatically split first name and last name when adding new items
label-split-en-name =
.label = Include English names when splitting/merging names
label-language = Manually Set Language
label-tools-info-1 = 💡 The
label-tools-info-2 = provides richer metadata inspection functionality
label-tools-linter = Linter Plugin
# WPS Plugin Installation
pref-group-wps = WPS Zotero Plugin
label-wps = Install Zotero Add-on for WPS
label-wps-help = Usage Help
label-install-wps-plugin-click =
.label = Click to Install
# About
pref-group-about = About
pref-help = Version { $version } Build { $time } ❤️
label-zotero-chinese = Zotero Chinese Community
pref-enable =
.label = Enable
================================================
FILE: addon/locale/en-US/preferences-translators.ftl
================================================
title = Chinese Community Translators List
github-link =
.label = Project Homepage
search-box =
.placeholder = Search translators
# Links
how-to-update-translators = How to update translators?
translators-dashboard = Translators Dashboard
# Buttons
request-new-translator = Request new translator
report-translator-bug = Report translator bug
================================================
FILE: addon/locale/en-US/progress.ftl
================================================
title = Jasmine Task Window
task-list = Task List
result-source = Source: { source }
result-title = Title: { title }
result-score = Match Score: { score }
confirm-close = There are still xxx pending tasks. Close the window anyway?
================================================
FILE: addon/locale/zh-CN/addon.ftl
================================================
plugin-name = 茉莉花
prefs-table-title = 标题
prefs-table-detail = 详情
tabpanel-lib-tab-label = 库标签
tabpanel-reader-tab-label = 阅读器标签
# Preference
select-download-folder = 选择下载文件保存目录
get-Chinese-styles = 获取中文社区样式…
info-translators-cn-updaing = 中文转换器正在更新中
info-best-speed-source-updated = 已更新为最快源:{ $source }
info-best-speed-source-failed = 选择最快源失败,请检查网络连接
# Preference translator table
th-filename = 文件名
th-label = 标签
th-local-update-time = 本地更新时间
th-remote-update-time = 远程更新时间
# Help menu
help-menu-chinese = Zotero 中文社区
help-menu-wiki = Zotero 使用文档
help-menu-addons = 插件商店
help-menu-csl = CSL样式下载
help-menu-translator = 中文文献抓取异常修复
# Menu
menu-metadata = 茉莉花抓取
menuitem-retrieveMetadata = 抓取期刊元数据
menuitem-retrieveMetadataForBook = 抓取书籍元数据
menuitem-find-attachment = 在下载文件夹中查找附件
menuitem-import-attachments = 从下载文件夹中导入附件
menu-tools = 小工具
menuitem-mergeName = 合并姓名
menuitem-splitName = 拆分姓名
menuitem-updateCNKICite = 更新知网引用数
# ui
CNKIcitation = 知网引用数
# popup window
citation = 引用
no-chinese-item-for-citation = 只有中文条目才能抓取引用数哦😀
update-translators-start = 开始更新转换器
update-successfully = 更新成功:{ $name }
update-failed = 更新失败:{ $name }
update-skipped = 已最新:{ $name }
update-translators-complete = 转换器更新完成,成功:{ $successCounts }, 失败:{ $failCounts }, 已最新:{ $skipCounts }
no-item-need-attachment = 这些条目已有附件或属于非学术类型条目
no-attachments-found = 未找到可导入的附件(PDF, CAJ等)
import-attachments-success = 从文件夹中导入附件成功
importing-attachments-is-running = 已有一个附件导入任务正在运行,请稍后再试。
# outline
outline = 显示书签大纲(茉莉花)
outline-expand-all = 展开所有
outline-collapse-all = 收起所有
outline-add = 添加书签
outline-delete = 删除书签
outline-save-to-pdf = 将大纲保存到PDF文件
outline-edit-placeholder = 请输入书签
outline-empty-prompt = 请点击上方按钮{ $icon }创建书签
outline-delete-confirm = 该节点有子节点,是否删除?
{" "}
如果删除,则所有子节点也会被删除。
# bookmark
bookmark = 显示书签(茉莉花)
bookmark-add = 添加书签
bookmark-delete = 删除书签
# Progress window
task-msg-header = 如果抓取异常需要帮助,请截图以下内容并联系开发者:[小红书l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)
task-already-exists = 任务已存在:{ $title }
================================================
FILE: addon/locale/zh-CN/mainWindow.ftl
================================================
item-section-example1-head-text =
.label = 插件模板: 条目信息
item-section-example1-sidenav-tooltip =
.tooltiptext = 这是插件模板面板(条目信息)
item-section-example2-head-text =
.label = 插件模板: 阅读器[{$status}]
item-section-example2-sidenav-tooltip =
.tooltiptext = 这是插件模板面板(阅读器)
item-section-example2-button-tooltip =
.tooltiptext = 移除此面板
================================================
FILE: addon/locale/zh-CN/preferences-main.ftl
================================================
# 元数据设置
pref-group-metadata = 中文元数据抓取设置
label-isMainlandChina =
.label = 当前位于中国大陆(不包括中国香港、中国澳门及中国台湾),海外用户请取消勾选
label-autoupdate-metadata =
.label = 添加中文PDF/CAJ时自动从知网抓取元数据
label-rename =
.label = 根据元数据重命名附件(依赖Attanger或zotmoov插件)
label-namepattern = 文件名解析模板
label-namepattern-auto =
.label = 智能识别
.tooltiptext = 利用茉莉花内置的算法智能识别文件名的作者或标题
label-namepattern-tg =
.label = 标题_作者(默认设置)
.tooltiptext =「标题_第一作者」格式命名文件,如「无人机多余度航空电子系统设计与应用_杨璐.caj」
label-namepattern-t =
.label = 标题
.tooltiptext =「标题」格式命名文件,如「无人机多余度航空电子系统设计与应用.caj」
label-namepattern-info = 文件名识别模板,从下拉菜单中选择对应格式或直接输入
label-namepattern-custom =
.label = 自定义
.tooltiptext = 设置自定义规则,识别文件名中的标题、作者信息用于元数据抓取
label-choose-namepattern =
.label = 选择模板
label-metadata-source = 元数据抓取来源
label-choose-source =
.label = 选择数据源
label-metadata-source-cnki =
.label = 中国知网CNKI
label-metadata-source-cvip =
.label = 维普期刊CVIP
namepattern-desc =
.tooltiptext = 根据文件名抓取知网元数据,文件名格式设置:
{"{"}%t{"}"}=标题,{"{"}%g{"}"}=作者,{"{"}%y{"}"}=年份,{"{"}%j{"}"}=其他(例如来源信息);分隔符依实情指定,可连续使用多个;不用考虑文件后缀名。
默认使用{"{"}%t{"}"}_{"{"}%g{"}"},可识别大部分知网下载的文件名格式,包括文件名只包括标题无分隔符号。
# 附件查找设置
pref-group-attachment = 本地附件查找设置
attachment-folder-desc =
.tooltiptext = 从下载目录中查找附件,并匹配到缺少附件的条目中。
此处请设置为浏览器的下载目录,插件就可以批量从下载目录中导入和查询附件。
label-pdf-match-folder = 附件下载文件夹
action-after-import = 附件匹配到条目之后,如何处理原始下载的附件文件:
label-choose-folder =
.label = 选择文件夹
nothing-label =
.label = 无须处理
backup-label =
.label = 备份附件
delete-label =
.label = 删除附件
action-after-import-desc =
.tooltiptext = 附件成功匹配到条目之后,您可以选择以下操作:
1. 无须处理:不做任何操作,下载的附件还在下载目录中;
2. 备份附件:将原始下载的附件文件备份到指定目录;
3. 删除附件:删除原始下载的附件文件,该附件已经匹配到条目,保存到Zotero中,可放心删除。
# 转换器设置
pref-group-translators = 中文转换器设置
label-translator-source = 转换器下载源
label-best-speed = 选择最快源
translatorSource-desc =
.tooltiptext = 选择转换器下载源,一般情况下不用切换。如果您无法下载中文转换器,可选择尝试其他源或点击 选择最快源 按钮。
label-auto-update-translators =
.label = 自动更新转换器
label-translators-force-update =
.label = 立即更新
label-translators-detail = 转换器详情
label-translators-detail-click = 点击查看
# 大纲书签设置
pref-group-bookmark = 大纲书签设置
label-disableZoteroOutline =
.label = 禁用 Zotero 自带的大纲
label-enableBookmark =
.label = 启用大纲书签
outline-desc =
.tooltiptext = 请注意,当您修改大纲或书签时,需要点击保存按钮才会保存到PDF文件中。默认将书签大纲信息与PDF文件分开保存。
# 小工具设置
pref-group-tools = 小工具设置
label-auto-split-name =
.label = 导入新条目时自动拆分姓名
label-split-en-name =
.label = 拆分/合并姓名时包括英文名
label-language = 手动设置语言
label-tools-info-1 = 💡
label-tools-info-2 = 提供更丰富的元数据检查功能
label-tools-linter = Linter 插件
# WPS 插件安装
pref-group-wps = WPS Zotero 插件
label-wps = 为 WPS 安装 Zotero 加载项
label-wps-help = 使用帮助
label-install-wps-plugin-click =
.label = 点击安装
# 其他
pref-group-about = 关于
pref-help = 版本 { $version } 构建于 { $time } ❤️
label-zotero-chinese = Zotero中文社区
pref-enable =
.label = 启用
================================================
FILE: addon/locale/zh-CN/preferences-translators.ftl
================================================
title = 中文社区转换器列表
github-link =
.label = 项目主页
search-box =
.placeholder = 搜索转换器
# Links
how-to-update-translators = 如何更新转换器?
translators-dashboard = 转换器看板
# Buttons
request-new-translator = 申请适配
report-translator-bug = 反馈错误
================================================
FILE: addon/locale/zh-CN/progress.ftl
================================================
title = 茉莉花任务窗口
task-list = 任务列表
result-source = 来源:{ source }
result-title = 标题:{ title }
result-score = 匹配度:{ score }
confirm-close = 还有 xxx 个任务未完成,是否关闭窗口?
================================================
FILE: addon/locale/zh-TW/addon.ftl
================================================
plugin-name = 茉莉花
prefs-table-title = 標題
prefs-table-detail = 詳細資料
tabpanel-lib-tab-label = 圖書館標籤
tabpanel-reader-tab-label = 閱讀器標籤
# Preference
select-download-folder = 選擇下載檔案儲存目錄
get-Chinese-styles = 取得中文社群樣式…
info-translators-cn-updaing = 中文轉換器正在更新中
info-best-speed-source-updated = 已更新為最快源:{ $source }
info-best-speed-source-failed = 選擇最快源失敗,請檢查網路連接
# Preference translator table
th-filename = 檔案名稱
th-label = 標籤
th-local-update-time = 本地更新時間
th-remote-update-time = 遠程更新時間
# Help menu
help-menu-chinese = Zotero 中文社群
help-menu-wiki = Zotero 使用說明
help-menu-addons = 插件商店
help-menu-csl = CSL樣式下載
help-menu-translator = 中文文獻抓取異常解決
# Menu
menu-metadata = 元資料抓取
menuitem-retrieveMetadata = 抓取期刊元資料
menuitem-retrieveMetadataForBook = 抓取書籍元資料
menuitem-find-attachment = 在資料夾中尋找附件
menuitem-import-attachments = 從資料夾中導入附件
menu-tools = 小工具
menuitem-mergeName = 合併姓名
menuitem-splitName = 拆分姓名
menuitem-updateCNKICite = 更新知網引用數
# ui
CNKIcitation = 知網引用數
# popup window
citation = 引用
no-chinese-item-for-citation = 只有中文項目才能抓取引用數哦😀
update-translators-start = 開始更新轉換器
update-successfully = 更新成功:{ $name }
update-failed = 更新失敗:{ $name }
update-skipped = 已最新:{ $name }
update-translators-complete = 轉換器更新完成,成功:{ $successCounts }, 失敗:{ $failCounts }, 已最新:{ $skipCounts }
no-item-need-attachment = 項目已存在附件或屬於非學術類型
import-attachments-success = 從資料夾中導入附件成功
importing-attachments-is-running = 已有附件匯入任務正在執行,請稍後再試
no-attachments-found = 未找到可匯入的附件(PDF、CAJ等)
# outline
outline = 顯示書籤大纲(茉莉花)
outline-expand-all = 展開所有
outline-collapse-all = 收起所有
outline-add = 添加書籤
outline-delete = 刪除書籤
outline-save-to-pdf = 將大纲儲存到PDF檔案
outline-edit-placeholder = 請輸入書籤
outline-empty-prompt = 請點擊上方按鈕{ $icon }創建書籤
outline-delete-confirm = 該節點有子節點,是否刪除?
{" "}
如果刪除,則所有子節點也會被刪除。
# bookmark
bookmark = 顯示書籤(茉莉花)
bookmark-add = 添加書籤
bookmark-delete = 刪除書籤
# Progress window
task-msg-header = 如果抓取異常需要幫助,請截圖以下內容並聯繫開發者:[小紅書l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)
task-already-exists = 已存在任務:{ $title }
================================================
FILE: addon/locale/zh-TW/mainWindow.ftl
================================================
item-section-example1-head-text =
.label = 插件模板: 条目信息
item-section-example1-sidenav-tooltip =
.tooltiptext = 这是插件模板面板(条目信息)
item-section-example2-head-text =
.label = 插件模板: 阅读器[{$status}]
item-section-example2-sidenav-tooltip =
.tooltiptext = 这是插件模板面板(阅读器)
item-section-example2-button-tooltip =
.tooltiptext = 移除此面板
================================================
FILE: addon/locale/zh-TW/preferences-main.ftl
================================================
# 元數據設定
pref-group-metadata = 中文元數據抓取設定
label-isMainlandChina =
.label = 目前位於中國大陸(不包括中國香港、中國澳門及中國台灣),海外用戶請取消勾選
label-autoupdate-metadata =
.label = 新增中文PDF/CAJ時自動從知網抓取元數據
label-rename =
.label = 根據元數據重新命名附件(依賴Attanger或zotmoov插件)
label-namepattern = 檔案名稱解析範本
label-namepattern-auto =
.label = 智能識別
.tooltiptext = 利用茉莉花內建的演算法智能識別檔案名稱的作者或標題
label-namepattern-tg =
.label = 標題_作者(預設設定)
.tooltiptext =「標題_第一作者」格式命名檔案,如「無人機多餘度航空電子系統設計與應用_楊璐.caj」
label-namepattern-t =
.label = 標題
.tooltiptext =「標題」格式命名檔案,如「無人機多餘度航空電子系統設計與應用.caj」
label-namepattern-info = 檔案名稱識別範本,從下拉選單中選擇對應格式或直接輸入
label-namepattern-custom =
.label = 自訂
.tooltiptext = 設定自訂規則,識別檔案名稱中的標題、作者資訊用於元數據抓取
label-choose-namepattern =
.label = 選擇範本
label-metadata-source = 元數據抓取來源
label-choose-source =
.label = 選擇資料來源
label-metadata-source-cnki =
.label = 中國知網CNKI
label-metadata-source-cvip =
.label = 維普期刊CVIP
label-pdf-match-folder = 附件匹配資料夾
label-choose-folder =
.label = 選擇資料夾
namepattern-desc =
.tooltiptext = 根據檔案名稱抓取知網元數據,檔案名稱格式設定:{"{"}%t{"}"}=標題,{"{"}%g{"}"}=作者,{"{"}%y{"}"}=年份,{"{"}%j{"}"}=其他(例如來源資訊);分隔符依實際情況指定,可連續使用多個;不用考慮檔案副檔名。預設使用{"{"}%t{"}"}_{"{"}%g{"}"},可識別大部分知網下載的檔案名稱格式,包括檔案名稱只包括標題無分隔符號。
# 轉換器設定
pref-group-translators = 中文轉換器設定
label-translator-source = 轉換器下載源
label-best-speed = 選擇最快源
translatorSource-desc =
.tooltiptext = 選擇轉換器下載源,一般情況下不用切換。如果您無法下載中文轉換器,可選擇嘗試其他源或點擊「選擇最快源」按鈕。
label-auto-update-translators =
.label = 自動更新轉換器
label-translators-force-update =
.label = 立即更新
label-translators-detail = 轉換器詳情
label-translators-detail-click = 點擊查看
# 附件設定
pref-group-attachment = 本地附件查找設定
attachment-folder-desc =
.tooltiptext = 從下載目錄中查找附件,並匹配到缺少附件的條目中
此處請設定為瀏覽器的下載目錄,插件即可批次從下載目錄中匯入及查詢附件。
label-pdf-match-folder = 附件下載資料夾
action-after-import = 附件匹配到條目之後,如何處理原始下載的附件檔案:
label-choose-folder =
.label = 選擇資料夾
nothing-label =
.label = 無須處理
backup-label =
.label = 備份附件
delete-label =
.label = 刪除附件
action-after-import-desc =
.tooltiptext = 附件成功匹配到條目之後,您可以選擇以下操作:
1. 無須處理:不做任何操作,下載的附件仍保留在下載目錄中;
2. 備份附件:將原始下載的附件檔案備份到指定目錄;
3. 刪除附件:刪除原始下載的附件檔案(該附件已匹配到條目並儲存到Zotero中)。
# 大綱書籤設定
pref-group-bookmark = 大綱書籤設定
label-disableZoteroOutline =
.label = 禁用 Zotero 自帶的大綱
label-enableBookmark =
.label = 啟用大綱書籤
outline-desc =
.tooltiptext = 請注意,當您修改大綱或書籤時,需要點擊「儲存」按鈕才會將變更保存至PDF檔案中。預設情況下,書籤與大綱資訊會與PDF檔案分開儲存。
# 小工具設定
pref-group-tools = 小工具設定
label-auto-split-name =
.label = 導入新條目時自動拆分姓名
label-split-en-name =
.label = 拆分/合併姓名時包括英文名
label-language = 手動設定語言
label-tools-info-1 = 💡
label-tools-info-2 = 提供更豐富的元數據檢查功能
label-tools-linter = Linter 插件
# WPS 插件安裝
pref-group-wps = WPS Zotero 插件
label-wps = 為 WPS 安裝 Zotero 加載項
label-wps-help = 使用說明
label-install-wps-plugin-click =
.label = 點擊安裝
# 其他
pref-group-about = 關於
pref-help = 版本 { $version } 建置於 { $time } ❤️
label-zotero-chinese = Zotero中文社群
pref-enable =
.label = 啟用
================================================
FILE: addon/locale/zh-TW/preferences-translators.ftl
================================================
title = 中文社群轉換器列表
github-link =
.label = 專案首頁
search-box =
.placeholder = 搜尋轉換器
# Links
how-to-update-translators = 如何更新轉換器?
translators-dashboard = 轉換器看板
# Buttons
request-new-translator = 請求适配
report-translator-bug = 報告錯誤
================================================
FILE: addon/locale/zh-TW/progress.ftl
================================================
title = 茉莉花任務視窗
task-list = 任務列表
result-source = 來源:{ source }
result-title = 標題:{ title }
result-score = 匹配度:{ score }
confirm-close = 仍有 xxx 個抓取任務未完成,是否關閉視窗?
================================================
FILE: addon/manifest.json
================================================
{
"manifest_version": 2,
"name": "__addonName__",
"version": "__buildVersion__",
"description": "__description__",
"homepage_url": "__homepage__",
"author": "__author__",
"icons": {
"48": "chrome/content/icons/icon@0.5x.png",
"96": "chrome/content/icons/icon.png"
},
"applications": {
"zotero": {
"id": "__addonID__",
"update_url": "__updateURL__",
"strict_min_version": "7.999",
"strict_max_version": "8.*.*"
}
}
}
================================================
FILE: addon/prefs.js
================================================
/* eslint-disable no-undef */
pref("firstRun", true);
pref("translatorsMended", false);
/* tools */
pref("autoSplitName", false);
pref("splitEnName", false);
pref("language", "zh");
/* retrieve metadata */
pref("autoUpdateMetadata", true);
pref("namePattern", "{%t}_{%g}");
pref("namePatternCustom", "{%t}");
pref("metadataSource", "CNKI");
pref("isMainlandChina", true);
pref("cnkiAttachmentCookie", "");
pref("similarityThresholdForMetaData", "0.6");
/* match pdf */
pref("pdfMatchFolder", "");
pref("actionAfterAttachmentImport", "backup")
pref("similarityThreshold", "0.8");
pref("topMatchCount", 3);
/* update translators */
pref("autoUpdateTranslators", true);
pref("translatorUpdateTime", "0");
pref("translatorSource", "");
/* bookmark */
pref("enableBookmark", true);
pref("newNodeAsChild", false);
pref("disableZoteroOutline", true);
================================================
FILE: doc/README-zhCN.md
================================================
# Zotero Plugin Template
[](https://www.zotero.org)
[](https://github.com/windingwind/zotero-plugin-template)
这是 [Zotero](https://www.zotero.org/) 的插件模板.
[English](../README.md) | [简体中文](./README-zhCN.md)
- 开发指南
- [📖 插件开发文档](https://zotero-chinese.com/plugin-dev-guide/) (中文版,尚不完善)
- [📖 Zotero 7 插件开发文档](https://www.zotero.org/support/dev/zotero_7_for_developers)
- 开发工具参考
- [🛠️ Zotero 插件工具包](https://github.com/windingwind/zotero-plugin-toolkit) | [API 文档](https://github.com/windingwind/zotero-plugin-toolkit/blob/master/docs/zotero-plugin-toolkit.md)
- [🛠️ Zotero 插件开发脚手架](https://github.com/northword/zotero-plugin-scaffold)
- [📜 Zotero 源代码](https://github.com/zotero/zotero)
- [ℹ️ Zotero 类型定义](https://github.com/windingwind/zotero-types)
- [📌 Zotero 插件模板](https://github.com/windingwind/zotero-plugin-template) (即本仓库)
> [!tip]
> 👁 Watch 本仓库,以及时收到修复或更新的通知.
## 使用此模板构建的插件
[](https://github.com/windingwind/zotero-better-notes)
[](https://github.com/windingwind/zotero-pdf-preview)
[](https://github.com/windingwind/zotero-pdf-translate)
[](https://github.com/windingwind/zotero-tag)
[](https://github.com/iShareStuff/ZoteroTheme)
[](https://github.com/MuiseDestiny/zotero-reference)
[](https://github.com/MuiseDestiny/zotero-citation)
[](https://github.com/MuiseDestiny/ZoteroStyle)
[](https://github.com/volatile-static/Chartero)
[](https://github.com/l0o0/tara)
[](https://github.com/redleafnew/delitemwithatt)
[](https://github.com/redleafnew/zotero-updateifsE)
[](https://github.com/northword/zotero-format-metadata)
[](https://github.com/inciteful-xyz/inciteful-zotero-plugin)
[](https://github.com/MuiseDestiny/zotero-gpt)
[](https://github.com/zoushucai/zotero-journalabbr)
[](https://github.com/MuiseDestiny/zotero-figure)
[](https://github.com/l0o0/jasminum)
[](https://github.com/lifan0127/ai-research-assistant)
[](https://github.com/daeh/zotero-markdb-connect)
如果你正在使用此库,我建议你将这个标志 ([](https://github.com/windingwind/zotero-plugin-template)) 放在 README 文件中:
```md
[](https://github.com/windingwind/zotero-plugin-template)
```
## Features 特性
- 事件驱动、函数式编程的可扩展框架;
- 简单易用,开箱即用;
- ⭐[新特性!]自动热重载!每当修改源码时,都会自动编译并重新加载插件;[详情请跳转→](#自动热重载)
- `src/modules/examples.ts` 中有丰富的示例,涵盖了插件中常用的大部分API (使用 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit);
- TypeScript 支持:
- 为使用 JavaScript 编写的 Zotero 源码提供全面的类型定义支持 (使用 [zotero-types](https://github.com/windingwind/zotero-types));
- 全局变量和环境设置;
- 插件开发/构建/发布工作流:
- 自动生成/更新插件版本、更新配置和设置环境变量 (`development`/`production`);
- 自动在 Zotero 中构建和重新加载代码;
- 自动发布到 GitHub ;
- 集成 Prettier 和 ES Lint;
## Examples 示例
此库提供了 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit) 中API的示例.
在 `src/examples.ts` 中搜索`@example` 查看示例. 这些示例在 `src/hooks.ts` 中调用演示.
### 基本示例(Basic Examples)
- registerNotifier
- registerPrefs, unregisterPrefs
### 快捷键示例(Shortcut Keys Examples)
- registerShortcuts
- exampleShortcutLargerCallback
- exampleShortcutSmallerCallback
- exampleShortcutConflictionCallback
### UI示例(UI Examples)

- registerStyleSheet(the official make-it-red example)
- registerRightClickMenuItem
- registerRightClickMenuPopup
- registerWindowMenuWithSeprator
- registerExtraColumn
- registerExtraColumnWithCustomCell
- registerCustomItemBoxRow
- registerLibraryTabPanel
- registerReaderTabPanel
### 首选项面板示例(Preference Pane Examples)

- Preferences bindings
- UI Events
- Table
- Locale
详情参见 [`src/modules/preferenceScript.ts`](./src/modules/preferenceScript.ts)
### 帮助示例(HelperExamples)

- dialogExample
- clipboardExample
- filePickerExample
- progressWindowExample
- vtableExample(See Preference Pane Examples)
### 指令行示例(PromptExamples)
Obsidian风格的指令输入模块,它通过接受文本来运行插件,并在弹出窗口中显示可选项.
使用 `Shift+P` 激活.

- registerAlertPromptExample
## 快速上手
### 0 环境要求
1. 安装 [beta 版 Zotero](https://www.zotero.org/support/beta_builds)
2. 安装 [Node.js](https://nodejs.org/en/) 和 [Git](https://git-scm.com/)
> [!note]
> 本指南假定你已经对 Zotero 插件的基本结构和工作原理有初步的了解. 如果你还不了解,请先参考[官方文档](https://www.zotero.org/support/dev/zotero_7_for_developers) 和[官方插件样例 Make It Red](https://github.com/zotero/make-it-red)。
### 1 创建你的仓库(Create Your Repo)
1. 点击 `Use this template`;
2. 使用 `git clone` 克隆上一步生成的仓库;
💡 从 GitHub Codespace 开始
_GitHub CodeSpace_ 使你可以直接开始开发而无需在本地下载代码/IDE/依赖.
重复下列步骤,仅需三十秒即可开始构建你的第一个插件!
- 点击首页 `Use this template` 按钮,随后点击 `Open in codespace`, 你需要登录你的 GitHub 账号.
- 等待 codespace 加载.
3. 进入项目文件夹;
### 2 配置模板和开发环境(Config Template Settings and Enviroment)
1. 修改 `./package.json` 中的设置,包括:
```json5
{
version: "", // 修改为 0.0.0
author: "",
description: "",
homepage: "",
config: {
addonName: "", // 插件名称
addonID: "", // 插件 ID 【重要:防止冲突】
addonRef: "", // 插件命名空间:元素前缀等
addonInstance: "", // 注册在 Zotero 根下的实例名
prefsPrefix: "extensions.zotero.${addonRef}", // 首选项的前缀
},
}
```
> [!warning]
> 注意设置 addonID 和 addonRef 以避免冲突.
如果你需要在 GitHub 以外的地方托管你的 XPI 包,请修改 `zotero-plugin.config.ts` 中的 `updateURL` 和 `xpiDownloadLink`。
2. 复制 Zotero 启动配置,填入 Zotero 可执行文件路径和 profile 路径.
> (可选项) 创建开发用 profile 目录:
>
> 此操作仅需执行一次: 使用 `/path/to/zotero -p` 启动 Zotero,创建一个新的配置文件并用作开发配置文件。
```sh
cp .env.example .env
vim .env
```
如果你维护了多个插件,可以将这些内容存入系统环境变量,以避免在每个插件中都需要重复设置。
3. 运行 `npm install` 以安装相关依赖
> 如果你使用 `pnpm` 作为包管理器,你需要添加 `public-hoist-pattern[]=*@types/bluebird*` 到`.npmrc`, 详情请查看 [zotero-types](https://github.com/windingwind/zotero-types?tab=readme-ov-file#usage) 的文档.
如果你使用 `npm install` 的过程中遇到了 `npm ERR! ERESOLVE unable to resolve dependency tree` ,这是由于上游依赖 typescript-eslint 导致的错误,请使用 `npm i -f` 命令进行安装。
### 3 开发插件
使用 `npm start` 启动开发服务器,它将:
- 在开发模式下预构建插件
- 启动 Zotero ,并让其从 `build/` 中加载插件
- 打开开发者工具(devtool)
- 监听 `src/**` 和 `addon/**`.
- 如果 `src/**` 修改了,运行 esbuild 并且重新加载
- 如果 `addon/**` 修改了,(在开发模式下)重新构建插件并且重新加载
#### 自动热重载
厌倦了无休止的重启吗?忘掉它,拥抱热加载!
1. 运行 `npm start`.
2. 编码. (是的,就这么简单)
当检测到 `src` 或 `addon` 中的文件修改时,插件将自动编译并重新加载.
💡 将此功能添加到现有插件的步骤
请参阅:[zotero-plugin-scaffold](https://github.com/northword/zotero-plugin-scaffold)。
#### 调试代码
你还可以:
- 在 Tools->Developer->Run Javascript 中测试代码片段;
- 使用 `Zotero.debug()` 调试输出. 在 Help->Debug Output Logging->View Output 查看输出;
- 调试 UI. Zotero 建立在 Firefox XUL 框架之上. 使用 [XUL Explorer](https://udn.realityripple.com/docs/Archive/Mozilla/XUL_Explorer) 等软件调试 XUL UI.
> XUL 文档:
### 4 构建插件
运行 `npm run build` 在生产模式下构建插件,构建的结果位于 `build/` 目录中.
构建步骤:
- 创建/清空 `build/`
- 复制 `addon/**` 到 `build/addon/**`
- 替换占位符:使用 `replace-in-file` 去替换在 `package.json` 中定义的关键字和配置 (`xhtml`、`.flt` 等)
- 准备本地化文件以避免冲突,查看官方文档了解更多(
- 重命名`**/*.flt` 为 `**/${addonRef}-*.flt`
- 在每个消息前加上 `addonRef-`
- 使用 Esbuild 来将 `.ts` 源码构建为 `.js`,从 `src/index.ts` 构建到`./build/addon/chrome/content/scripts`
- (仅在生产模式下工作) 压缩 `./build/addon` 目录为 `./build/*.xpi`
- (仅在生产模式下工作) 准备 `update.json` 或 `update-beta.json`
> [!note]
>
> **Dev & prod 两者有什么区别?**
>
> - 此环境变量存储在 `Zotero.${addonInstance}.data.env` 中,控制台输出在生产模式下被禁用.
> - 你可以根据此变量决定用户无法查看/使用的内容.
> - 在生产模式下,构建脚本将自动打包插件并更新 `update.json`.
### 5 发布
如果要构建和发布插件,运行如下指令:
```shell
# version increase, git add, commit and push
# then on ci, npm run build, and release to GitHub
npm run release
```
> [!note]
> 在此模板中,release-it 被配置为在本地更新版本号、提交并推送标签,随后 GitHub Action 将重新构建插件并将 XPI 发布到 GitHub Release.
#### 关于预发布
该模板将 `prerelease` 定义为插件的测试版,当你在 release-it 中选择 `prerelease` 版本 (版本号中带有 `-` ),构建脚本将创建一个 `update-beta.json` 给预发布版本使用,这将确保常规版本的用户不会自动更新到测试版,只有手动下载并安装了测试版的用户才能自动更新到下一个测试版. 当下一个正式版本更新时,脚本将同步更新 `update.json` 和 `update-beta.json`,这将使正式版和测试版用户都可以更新到最新的正式版.
> [!warning]
> 严格来说,区分 Zotero 6 和 Zotero 7 兼容的插件版本应该通过 `update.json` 的 `addons.__addonID__.updates[]` 中分别配置 `applications.zotero.strict_min_version`,这样 Zotero 才能正确识别,详情在 Zotero 7 开发文档(.
## Details 更多细节
### 关于Hooks(About Hooks)
> 可以在 [`src/hooks.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/hooks.ts) 中查看更多
1. 当在 Zotero 中触发安装/启用/启动时,`bootstrap.js` > `startup` 被调用
- 等待 Zotero 就绪
- 加载 `index.js` (插件代码的主入口,从 `index.ts` 中构建)
- 如果是 Zotero 7 以上的版本则注册资源
2. 主入口 `index.js` 中,插件对象被注入到 `Zotero` ,并且 `hooks.ts` > `onStartup` 被调用.
- 初始化插件需要的资源,包括通知监听器、首选项面板和UI元素.
3. 当在 Zotero 中触发卸载/禁用时,`bootstrap.js` > `shutdown` 被调用.
- `events.ts` > `onShutdown` 被调用. 移除 UI 元素、首选项面板或插件创建的任何内容.
- 移除脚本并释放资源.
### 关于全局变量(About Global Variables)
> 可以在 [`src/index.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/index.ts)中查看更多
bootstrap插件在沙盒中运行,但沙盒中没有默认的全局变量,例如 `Zotero` 或 `window` 等我们曾在overlay插件环境中使用的变量.
此模板将以下变量注册到全局范围:
```ts
Zotero, ZoteroPane, Zotero_Tabs, window, document, rootURI, ztoolkit, addon;
```
### 创建元素 API(Create Elements API)
插件模板为 bootstrap 插件提供了一些新的API. 我们有两个原因使用这些 API,而不是使用 `createElement/createElementNS`:
- 在 bootstrap 模式下,插件必须在推出(禁用或卸载)时清理所有 UI 元素,这非常麻烦. 使用 `createElement`,插件模板将维护这些元素. 仅仅在退出时 `unregisterAll` .
- Zotero 7 需要 createElement()/createElementNS() → createXULElement() 来表示其他的 XUL 元素,而 Zotero 6 并不支持 `createXULElement`. 类似于 React.createElement 的API `createElement` 检测 namespace(xul/html/svg) 并且自动创建元素,返回元素为对应的 TypeScript 元素类型.
```ts
createElement(document, "div"); // returns HTMLDivElement
createElement(document, "hbox"); // returns XUL.Box
createElement(document, "button", { namespace: "xul" }); // manually set namespace. returns XUL.Button
```
### 关于 Zotero API(About Zotero API)
Zotero 文档已过时且不完整,克隆 并全局搜索关键字.
> ⭐[zotero-types](https://github.com/windingwind/zotero-types) 提供了最常用的 Zotero API,在默认情况下它被包含在此模板中. 你的 IDE 将为大多数的 API 提供提醒.
猜你需要:查找所需 API的技巧
在 `.xhtml`/`.flt` 文件中搜索 UI 标签,然后在 locale 文件中找到对应的键. ,然后在 `.js`/`.jsx` 文件中搜索此键.
### 目录结构(Directory Structure)
本部分展示了模板的目录结构.
- 所有的 `.js/.ts` 代码都在 `./src`;
- 插件配置文件:`./addon/manifest.json`;
- UI 文件: `./addon/chrome/content/*.xhtml`.
- 区域设置文件: `./addon/locale/**/*.flt`;
- 首选项文件: `./addon/prefs.js`;
> 不要在 `prefs.js` 中换行
```shell
.
|-- .eslintrc.json # eslint conf
|-- .gitattributes # git conf
|-- .github/ # github conf
|-- .gitignore # git conf
|-- .prettierrc # prettier conf
|-- .release-it.json # release-it conf
|-- .vscode # vs code conf
| |-- extensions.json
| |-- launch.json
| |-- setting.json
| `-- toolkit.code-snippets
|-- package-lock.json # npm conf
|-- package.json # npm conf
|-- LICENSE
|-- README.md
|-- addon
| |-- bootstrap.js # addon load/unload script, like a main.c
| |-- chrome
| | `-- content
| | |-- icons/
| | |-- preferences.xhtml # preference panel
| | `-- zoteroPane.css
| |-- locale # locale
| | |-- en-US
| | | |-- addon.ftl
| | | `-- preferences.ftl
| | `-- zh-CN
| | |-- addon.ftl
| | `-- preferences.ftl
| |-- manifest.json # addon config
| `-- prefs.js
|-- build/ # build dir
|-- scripts # scripts for dev
| |-- build.mjs # script to build plugin
| |-- scripts.mjs # scripts send to Zotero, such as reload, openDevTool, etc
| |-- server.mjs # script to start a development server
| |-- start.mjs # script to start Zotero process
| |-- stop.mjs # script to kill Zotero process
| |-- utils.mjs # utils functions for dev scripts
| |-- update-template.json # template of `update.json`
| `-- zotero-cmd-template.json # template of local env
|-- src # source code
| |-- addon.ts # base class
| |-- hooks.ts # lifecycle hooks
| |-- index.ts # main entry
| |-- modules # sub modules
| | |-- examples.ts
| | `-- preferenceScript.ts
| `-- utils # utilities
| |-- locale.ts
| |-- prefs.ts
| |-- wait.ts
| `-- window.ts
|-- tsconfig.json # https://code.visualstudio.com/docs/languages/jsconfig
|-- typings # ts typings
| `-- global.d.ts
`-- update.json
```
## Disclaimer 免责声明
在 AGPL 下使用此代码. 不提供任何保证. 遵守你所在地区的法律!
如果你想更改许可,请通过 与我联系.
================================================
FILE: eslint.config.mjs
================================================
// @ts-check Let TS check this config file
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["build/**", "dist/**", "node_modules/**", "scripts/"],
},
{
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
rules: {
"no-restricted-globals": [
"error",
{ message: "Use `Zotero.getMainWindow()` instead.", name: "window" },
{
message: "Use `Zotero.getMainWindow().document` instead.",
name: "document",
},
{
message: "Use `Zotero.getActiveZoteroPane()` instead.",
name: "ZoteroPane",
},
"Zotero_Tabs",
],
"@typescript-eslint/ban-ts-comment": [
"warn",
{
"ts-expect-error": "allow-with-description",
"ts-ignore": "allow-with-description",
"ts-nocheck": "allow-with-description",
"ts-check": "allow-with-description",
},
],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": [
"off",
{
ignoreRestArgs: true,
},
],
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
================================================
FILE: package.json
================================================
{
"name": "jasminum",
"version": "1.1.31",
"description": "一个简单的 Zotero 中文插件",
"config": {
"addonName": "Jasminum",
"addonID": "jasminum@linxzh.com",
"addonRef": "jasminum",
"addonInstance": "Jasminum",
"prefsPrefix": "extensions.jasminum"
},
"repository": {
"type": "git",
"url": "git+https://github.com/l0o0/jasminum.git"
},
"author": "l0o0",
"bugs": {
"url": "https://github.com/l0o0/jasminum/issues"
},
"homepage": "https://github.com/l0o0/jasminum#readme",
"license": "AGPL-3.0-or-later",
"scripts": {
"start": "zotero-plugin serve",
"build": "tsc --noEmit && zotero-plugin build",
"lint": "prettier --write . && eslint . --fix",
"release": "zotero-plugin release",
"test": "echo \"Error: no test specified\" && exit 1",
"update-deps": "npm update --save"
},
"dependencies": {
"pdf-lib": "^1.17.1",
"string-similarity": "^4.0.4",
"zotero-plugin-toolkit": "5.1.0-beta.4"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/node": "^25.0.10",
"@types/string-similarity": "^4.0.2",
"eslint": "^9.27.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.0",
"zotero-plugin-scaffold": "^0.8.0",
"zotero-types": "4.1.0-beta.4"
},
"prettier": {
"printWidth": 80,
"tabWidth": 2,
"endOfLine": "lf",
"overrides": [
{
"files": [
"*.xhtml"
],
"options": {
"htmlWhitespaceSensitivity": "css"
}
}
]
},
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
}
================================================
FILE: src/addon.ts
================================================
import hooks from "./hooks";
import { createZToolkit } from "./utils/ztoolkit";
import { Progress } from "./modules/progress";
import { VirtualizedTableHelper } from "zotero-plugin-toolkit";
import { MyCookieSandbox } from "./utils/cookiebox";
import { getOutlineFromPDF } from "./modules/outline/outline";
import { TaskRunner } from "./utils/task";
import { requestDocument } from "./utils/http";
class Addon {
public data: {
alive: boolean;
// Env type, see build.js
env: "development" | "production";
ztoolkit: ZToolkit;
locale?: {
current: any;
};
prefs?: {
window: Window;
};
progress: Progress;
windows: Record;
translators: {
window?: Window;
helper?: VirtualizedTableHelper;
rows: TableRow[];
allRows: TableRow[];
selected?: string;
updating?: boolean;
};
myCookieSandbox: MyCookieSandbox;
isImportingAttachments: boolean;
};
// Lifecycle hooks
public hooks: typeof hooks;
// APIs
public api: object;
public taskRunner: TaskRunner;
constructor() {
this.data = {
alive: true,
env: __env__,
ztoolkit: createZToolkit(),
progress: new Progress(),
windows: {},
translators: {
rows: [],
allRows: [],
updating: false,
},
myCookieSandbox: new MyCookieSandbox(),
isImportingAttachments: false,
};
this.hooks = hooks;
this.api = { getOutlineFromPDF, requestDocument };
this.taskRunner = new TaskRunner();
}
}
export default Addon;
================================================
FILE: src/hooks.ts
================================================
import { config } from "../package.json";
import { initLocale } from "./utils/locale";
import {
registerPrefsPane,
onPrefsWindowLoad,
initPrefs,
} from "./modules/preferences/main";
import { createZToolkit } from "./utils/ztoolkit";
import { registerMenu } from "./modules/menu";
import {
registerExtraColumnWithCustomCell,
registerNotifiers,
registerTab,
} from "./modules/notifier";
import { injectStylesLink } from "./modules/styles";
import { updateTranslators } from "./modules/translators";
import { getPref } from "./utils/prefs";
async function onStartup() {
await Promise.all([
Zotero.initializationPromise,
Zotero.unlockPromise,
Zotero.uiReadyPromise,
]);
initLocale();
registerPrefsPane();
initPrefs();
registerNotifiers();
registerMenu();
registerTab();
await registerExtraColumnWithCustomCell();
injectStylesLink();
// @ts-ignore - Not typed.
await Zotero.Promise.delay(1000);
await Promise.all(
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
);
}
async function onMainWindowLoad(win: Window): Promise {
// Create ztoolkit for every window
addon.data.ztoolkit = createZToolkit();
// @ts-ignore - Not typed.
await Zotero.Promise.delay(1000);
if (getPref("autoUpdateTranslators")) {
// @ts-ignore - Not typed.
await Zotero.Promise.delay(10000);
ztoolkit.log("auto update translators");
updateTranslators();
}
}
function onShutdown(): void {
ztoolkit.unregisterAll();
// Remove addon object
addon.data.alive = false;
// @ts-ignore - Plugin instance is not typed
delete Zotero[config.addonInstance];
}
// Add your hooks here. For element click, etc.
// Keep in mind hooks only do dispatch. Don't add code that does real jobs in hooks.
// Otherwise the code would be hard to read and maintain.
export default {
onStartup,
onShutdown,
onMainWindowLoad,
onPrefsWindowLoad,
};
================================================
FILE: src/index.ts
================================================
import { BasicTool } from "zotero-plugin-toolkit";
import Addon from "./addon";
import { config } from "../package.json";
const basicTool = new BasicTool();
// @ts-ignore - Plugins instance not typed.
if (!basicTool.getGlobal("Zotero")[config.addonInstance]) {
_globalThis.addon = new Addon();
defineGlobal("ztoolkit", () => {
return _globalThis.addon.data.ztoolkit;
});
// @ts-ignore - Plugins instance not typed.
Zotero[config.addonInstance] = addon;
}
function defineGlobal(name: Parameters[0]): void;
function defineGlobal(name: string, getter: () => any): void;
function defineGlobal(name: string, getter?: () => any) {
Object.defineProperty(_globalThis, name, {
get() {
return getter ? getter() : basicTool.getGlobal(name);
},
});
}
================================================
FILE: src/modules/attachments/index.ts
================================================
import { getPref } from "../../utils/prefs";
import { LocalAttachmentService } from "./localMatch";
const localService = new LocalAttachmentService();
export async function attachmentSearch(task: AttachmentTask): Promise {
const attachmentSearchResults = await localService.searchAttachments(task);
if (!attachmentSearchResults || attachmentSearchResults.length === 0) {
task.addMsg("No matching attachments found in local.");
task.status = "fail";
return;
} else if (attachmentSearchResults.length === 1) {
task.searchResults = attachmentSearchResults;
task.resultIndex = 0;
task.addMsg("Found one matching attachment in local.");
} else {
task.status = "multiple_results";
task.searchResults = attachmentSearchResults;
task.addMsg(
`Found ${attachmentSearchResults.length} matching attachments in local.`,
);
}
}
export async function importAttachment(task: AttachmentTask): Promise {
// Maybe oneday I will support remote attachment import
await localService.importAttachment(task);
// Action after import
await actionAfterImport(task.searchResults![task.resultIndex!].url);
}
export async function actionAfterImport(
attachmentPath: string,
action?: string,
): Promise {
const a = action || getPref("actionAfterAttachmentImport") || "nothing";
const attachmentName = PathUtils.filename(attachmentPath);
const backupFolder = PathUtils.join(
getPref("pdfMatchFolder"),
"jasminum-backup",
);
const backupFile = PathUtils.join(backupFolder, attachmentName);
ztoolkit.log("Action after import: ", a, attachmentName);
switch (a) {
case "nothing":
ztoolkit.log("No action after import.");
break;
case "backup":
ztoolkit.log("Backing up the attachment...");
await IOUtils.makeDirectory(backupFolder, { ignoreExisting: true });
await IOUtils.move(attachmentPath, backupFile);
break;
case "delete":
ztoolkit.log("Deleting the attachment...");
await IOUtils.remove(attachmentPath);
break;
}
}
================================================
FILE: src/modules/attachments/localMatch.ts
================================================
import { compareTwoStrings } from "string-similarity";
import { getPref } from "../../utils/prefs";
import { isChineseAttachmentFilename } from "../../utils/detect";
// Return full path of the attachments.
export async function findAttachmentsInFolder(
folder?: string,
): Promise {
if (!folder) folder = getPref("pdfMatchFolder");
ztoolkit.log(folder);
return (await IOUtils.getChildren(folder)).filter((filename) => {
ztoolkit.log(filename);
return isChineseAttachmentFilename(PathUtils.filename(filename));
});
}
export class LocalAttachmentService implements AttachmentService {
async searchAttachments(
task: AttachmentTask,
): Promise {
ztoolkit.log("Searching for local attachments...");
const threshold = parseFloat(getPref("similarityThreshold"));
const top = getPref("topMatchCount");
const searchString = task.item.getField("title");
const attachmentFilenames = await findAttachmentsInFolder();
ztoolkit.log(attachmentFilenames);
if (!attachmentFilenames || attachmentFilenames.length === 0) {
return null;
}
// 创建包含评分和文件名的对象数组
const scoredItems = attachmentFilenames.map((filename) => {
const name = PathUtils.filename(filename);
const name_no_ext = name.replace(/\.(pdf|caj|kdh|nh)$/i, "");
const score = compareTwoStrings(
searchString.toUpperCase(),
name_no_ext.toUpperCase(),
);
ztoolkit.log(
searchString.toUpperCase(),
name,
name_no_ext.toUpperCase(),
score,
);
return {
title: name,
filename: name,
score: score,
url: filename,
source: "local",
};
});
ztoolkit.log(scoredItems);
// 按评分降序排序
const sortedItems = scoredItems.sort((a, b) => b.score - a.score);
// 过滤阈值并取前3项
const topMatches = sortedItems
.filter((item) => item.score >= threshold)
.slice(0, top);
return topMatches.length > 0 ? topMatches : null;
}
async importAttachment(task: AttachmentTask): Promise {
if (
!task.searchResults ||
task.searchResults.length === 0 ||
task.resultIndex === undefined
) {
task.addMsg("Found attachment, but import failed.");
task.status = "fail";
return;
}
const searchResult = task.searchResults[task.resultIndex];
const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = {
file: searchResult.url,
parentItemID: task.item.id,
title: `FullText_by_Jasminum.${searchResult.title}`,
};
const importItem = await Zotero.Attachments.importFromFile(importOptions);
if (importItem) {
task.status = "success";
}
}
}
================================================
FILE: src/modules/menu.ts
================================================
import { MenuitemOptions } from "zotero-plugin-toolkit/dist/managers/menu";
import { config } from "../../package.json";
import { getString } from "../utils/locale";
import {
mergeName,
splitName,
updateCNKICite,
importAttachmentsFromFolder,
handleAttachmentMenu,
} from "./tools";
import { isChineseTopAttachment, isChinsesSnapshot } from "../utils/detect";
const metaddataMenuItems: MenuitemOptions[] = [
{
tag: "menuitem",
label: "retrieveMetadata",
icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`,
isHidden: (_elm, _ev) =>
Zotero.getActiveZoteroPane()
.getSelectedItems()
.some((item) => {
return !(isChineseTopAttachment(item) || isChinsesSnapshot(item));
}),
commandListener: async () => {
const items = Zotero.getActiveZoteroPane().getSelectedItems();
for (const item of items) {
await addon.taskRunner.createAndAddTask(
item,
isChineseTopAttachment(item) ? "attachment" : "snapshot",
);
}
},
},
{
tag: "menuitem",
label: "retrieveMetadataForBook",
icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`,
isHidden: () => true,
// getVisibility: (_elm, _ev) =>
// Zotero.getActiveZoteroPane()
// .getSelectedItems()
// .some((item) => {
// return isChineseTopAttachment(item);
// }),
commandListener: () => {
// @ts-ignore - The plugin instance is not typed.
Zotero[config.addonInstance].scraper.search(
Zotero.getActiveZoteroPane().getSelectedItems()[0],
);
},
},
];
const toolsMenuItems: MenuitemOptions[] = [
{
tag: "menuitem",
label: "mergeName",
icon: `chrome://${config.addonRef}/content/icons/name.png`,
commandListener: () => {
for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) {
mergeName(item);
}
},
},
{
tag: "menuitem",
label: "splitName",
icon: `chrome://${config.addonRef}/content/icons/name.png`,
commandListener: () => {
for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) {
splitName(item);
}
},
},
{
tag: "menuitem",
label: "updateCNKICite",
icon: `chrome://${config.addonRef}/content/icons/cite.png`,
commandListener: async () => {
await updateCNKICite(Zotero.getActiveZoteroPane().getSelectedItems());
},
},
{
tag: "menuitem",
label: "find-attachment",
icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`,
commandListener: () => {
handleAttachmentMenu("item");
},
},
];
export function registerMenu() {
const separatorMenu: MenuitemOptions = {
tag: "menuseparator",
id: `${config.addonRef}-separator`,
isHidden: (_event) =>
Zotero.getActiveZoteroPane()
.getSelectedItems()
.some((item) => {
return !(
isChineseTopAttachment(item) ||
isChinsesSnapshot(item) ||
(item.isTopLevelItem() && item.isRegularItem())
);
}),
};
const metadataMenu: MenuitemOptions = {
tag: "menu",
label: getString("menu-metadata"),
id: `${config.addonRef}-metadata-menu`,
icon: `chrome://${config.addonRef}/content/icons/icon.png`,
children: metaddataMenuItems.map((subOption) => {
const label = subOption.label as string;
subOption.id = `${config.addonRef}-menuitem-${label}`;
subOption.label = getString(`menuitem-${label}`);
return subOption;
}),
isHidden: (_event) =>
Zotero.getActiveZoteroPane()
.getSelectedItems()
.some((item) => {
return !(isChineseTopAttachment(item) || isChinsesSnapshot(item));
}),
};
const toolsMenu: MenuitemOptions = {
tag: "menu",
label: getString("menu-tools"),
id: `${config.addonRef}-tools-menu`,
icon: `chrome://${config.addonRef}/content/icons/icon.png`,
children: toolsMenuItems.map((subOption) => {
const label = subOption.label as string;
subOption.id = `${config.addonRef}-menuitem-${label}`;
subOption.label = getString(`menuitem-${label}`);
return subOption;
}),
isHidden: () =>
Zotero.getActiveZoteroPane()
.getSelectedItems()
.some((item) => {
return !(item.isTopLevelItem() && item.isRegularItem());
}),
};
ztoolkit.Menu.register("item", separatorMenu);
ztoolkit.Menu.register("item", metadataMenu);
ztoolkit.Menu.register("item", toolsMenu);
const attachmentMenu: MenuitemOptions = {
tag: "menuitem",
label: getString("menuitem-find-attachment"),
id: `${config.addonRef}-attachment-menu`,
icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`,
commandListener: () => {
handleAttachmentMenu("collection");
},
isHidden: () =>
Zotero.getActiveZoteroPane().getSelectedCollection() === undefined
? true
: false,
};
const importAttachmentMenu: MenuitemOptions = {
tag: "menuitem",
label: getString("menuitem-import-attachments"),
id: `${config.addonRef}-attachment-menu`,
icon: `chrome://${config.addonRef}/content/icons/folder-import.svg`,
commandListener: async () => {
await importAttachmentsFromFolder();
},
isHidden: () =>
Zotero.getActiveZoteroPane().getSelectedCollection() === undefined
? true
: false,
};
ztoolkit.Menu.register("collection", attachmentMenu);
ztoolkit.Menu.register("collection", importAttachmentMenu);
// ztoolkit.Menu.register("item", {
// tag: "menuitem",
// label: "TEST",
// commandListener: async () => {
// // downloadTranslator(true);
// const item = Zotero.getActiveZoteroPane().getSelectedItems()[0];
// const title = await getPDFTitle(item.id);
// ztoolkit.log(title);
// },
// });
// Disable in collection
// ztoolkit.Menu.register("collection", metadataMenu);
}
================================================
FILE: src/modules/notifier.ts
================================================
import { config } from "../../package.json";
import { getString } from "../utils/locale";
import { getPref } from "../utils/prefs";
import { isChineseTopAttachment } from "../utils/detect";
import { registerOutline } from "./outline";
import { splitName } from "./tools";
/**
* A wrap for Zotero.Notifier.registerObserver,
* which will automatically unregister the observer when the addon is disabled.
*/
function registerNotifier(
onNotify: (
event: string,
type: string,
ids: number[] | string[],
extraData: { [key: string]: any },
) => void,
types: _ZoteroTypes.Notifier.Type[],
) {
const callback = {
notify: async (
event: string,
type: string,
ids: number[] | string[],
extraData: { [key: string]: any },
) => {
if (!addon?.data.alive) {
unregisterNotifier(notifierID);
return;
}
onNotify(event, type, ids, extraData);
},
};
// Register the callback in Zotero as an item observer
const notifierID = Zotero.Notifier.registerObserver(callback, types);
Zotero.Plugins.addObserver({
shutdown: ({ id }) => {
if (id === config.addonID) unregisterNotifier(notifierID);
},
});
}
function unregisterNotifier(notifierID: string) {
Zotero.Notifier.unregisterObserver(notifierID);
}
/**
* Register notifiers for the addon at startup hooks.
*/
export function registerNotifiers() {
registerNotifier(onAddItem, ["item"]);
// registerNotifier(onOpenTab, ["tab"]);
}
async function onAddItem(
event: string,
type: string,
ids: Array,
extraData: { [key: string]: any },
) {
// ztoolkit.log(`notify: add item, event: ${event}, type: ${type}, ids: ${ids}`);
if (event !== "add" || type !== "item") return;
for (const id of ids) {
const item = Zotero.Items.get(id);
if (getPref("autoUpdateMetadata")) {
if (isChineseTopAttachment(item)) {
await addon.taskRunner.createAndAddTask(item, "attachment");
}
}
if (getPref("autoSplitName")) {
splitName(item);
}
}
}
// TODO: Complete the notifier.
// async function onOpenTab(
// event: string,
// type: string,
// ids: Array,
// extraData: { [key: string]: any },
// ) {
// const id = ids[0];
// if (
// (event == "select" || event == "load") &&
// type == "tab" &&
// extraData[id].type == "reader"
// ) {
// ztoolkit.log("onOpenTab", event, type, extraData);
// if (getPref("enableBookmark")) {
// await registerOutline(id as string);
// } else {
// ztoolkit.log("Jasminum bookmark is disabled");
// }
// }
// }
export async function registerExtraColumnWithCustomCell() {
const registeredDataKey = Zotero.ItemTreeManager.registerColumn({
dataKey: "CNKIcitation",
label: getString("CNKIcitation"),
pluginID: config.addonID,
dataProvider: (item, dataKey) => {
// 网友提供的特殊字符,方便排序
return ztoolkit.ExtraField.getExtraField(item, "CNKICite") || "\u2068";
},
// @ts-ignore - Not typed.
// renderCell(index, data, column, isFirstColumn, doc) {
// const span = doc.createElementNS("http://www.w3.org/1999/xhtml", "span");
// span.className = `cell ${column.className}`;
// span.title = getString("CNKIcitation");
// span.innerText = data == "" ? null : data;
// return span;
// },
});
}
// For Outline register.
export function registerTab() {
Zotero.Reader.registerEventListener(
"renderToolbar",
tabRegisterCallback,
config.addonID,
);
// Zotero.Reader.registerEventListener(
// "renderTextSelectionPopup",
// (event: any) => {
// ztoolkit.log(event);
// event.append("Jasminum
");
// },
// );
}
async function tabRegisterCallback(event: any) {
if (getPref("enableBookmark")) {
const { reader } = event;
await registerOutline(reader.tabID);
} else {
ztoolkit.log("Jasminum bookmark is disabled");
}
}
================================================
FILE: src/modules/outline/bookmark.ts
================================================
import { version } from "../../../package.json";
import { getString } from "../../utils/locale";
import { ICONS } from "./style";
import { OUTLINE_SCHEMA } from "./outline";
export const BOOKMARK_SCHEMA = OUTLINE_SCHEMA;
export const DEFAULT_BOOKMARK_FONT_SIZE = 13; // Default font size for bookmarks
// 学生友好的清新现代颜色
export const DEFAULT_BOOKMARK_COLORS = [
"#FF6B6B", // 珊瑚红
"#4ECDC4", // 薄荷绿
"#45B7D1", // 天空蓝
"#96CEB4", // 薄荷色
"#FECA57", // 向日葵黄
"#FF9FF3", // 粉紫色
"#54A0FF", // 宝蓝色
"#5F27CD", // 紫罗兰
"#00D2D3", // 青绿色
"#FF9F43", // 橙色
"#10AC84", // 翡翠绿
"#EE5A24", // 朱砂橙
];
// 获取随机颜色
function getRandomBookmarkColor(): string {
const randomIndex = Math.floor(
Math.random() * DEFAULT_BOOKMARK_COLORS.length,
);
return DEFAULT_BOOKMARK_COLORS[randomIndex];
}
function migrateBookmarkInfo(
raw: any,
fromSchema: number,
): { bookmarks: BookmarkNode[]; baseFontSize: number } {
let bookmarks: BookmarkNode[] = raw.bookmarks ?? [];
let baseFontSize = DEFAULT_BOOKMARK_FONT_SIZE;
// v1 → v2: add baseFontSize and bookmark color
if (fromSchema < 2) {
baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;
bookmarks = bookmarks.map((b: any) => ({
...b,
color: b.color || getRandomBookmarkColor(),
}));
}
// Future v2 → v3 migrations go here
return { bookmarks, baseFontSize };
}
function getReaderPagePosition(): PdfPosition {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const primaryView = reader._internalReader
._primaryView as _ZoteroTypes.Reader.PDFView;
const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication;
const doc = primaryView._iframeWindow!.document;
const container = doc.getElementById("viewerContainer")!;
const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1;
const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex);
const viewport = pageView.viewport;
const scrollX = 0;
const scrollY = container.scrollTop - pageView.div.offsetTop;
const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY);
return { position: { pageIndex, rects: [[x, y, x, y]] } };
}
export async function saveBookmarksToJSON(
item?: Zotero.Item,
bookmarks?: BookmarkNode[],
baseFontSize?: number,
) {
if (!bookmarks) {
bookmarks = getBookmarksFromPage();
}
if (!item) {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
item = reader._item;
}
// Get current baseFontSize if not provided
if (baseFontSize === undefined) {
const currentInfo = await loadBookmarkInfoFromJSON(item);
baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;
}
const bookmarkInfo: BookmarkInfo = {
info: {
itemID: item.id,
schema: BOOKMARK_SCHEMA,
jasminumVersion: version,
baseFontSize: baseFontSize,
},
bookmarks: bookmarks,
};
const bookmarkStr = JSON.stringify(bookmarkInfo);
const bookmarkPath = PathUtils.join(
Zotero.DataDirectory.dir,
"storage",
item.key,
"jasminum-bookmarks.json",
);
await Zotero.File.putContentsAsync(bookmarkPath, bookmarkStr);
ztoolkit.log("Save bookmarks to JSON");
}
export async function loadBookmarkInfoFromJSON(
item: Zotero.Item,
): Promise<{ bookmarks: BookmarkNode[]; baseFontSize: number } | null> {
const bookmarkPath = PathUtils.join(
Zotero.DataDirectory.dir,
"storage",
item.key,
"jasminum-bookmarks.json",
);
const isFileExist = await IOUtils.exists(bookmarkPath);
if (!isFileExist) {
ztoolkit.log(`Bookmarks json is missing: ${bookmarkPath}`);
return null;
} else {
const content = (await Zotero.File.getContentsAsync(
bookmarkPath,
)) as string;
const tmp = JSON.parse(content);
const fileSchema = tmp.info?.schema ?? 1;
if (fileSchema < BOOKMARK_SCHEMA) {
// Migrate old bookmark data instead of discarding
const migrated = migrateBookmarkInfo(tmp, fileSchema);
await saveBookmarksToJSON(
item,
migrated.bookmarks,
migrated.baseFontSize,
);
return migrated;
} else {
const bookmarkInfo = JSON.parse(content) as BookmarkInfo;
return {
bookmarks: bookmarkInfo.bookmarks,
baseFontSize:
bookmarkInfo.info.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE,
};
}
}
}
export async function loadBookmarksFromJSON(
item: Zotero.Item,
): Promise {
const info = await loadBookmarkInfoFromJSON(item);
return info?.bookmarks ?? null;
}
export function getBookmarksFromPage(): BookmarkNode[] {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const rootUL = reader._iframeWindow!.document.querySelector(
"#bookmark-root-list",
);
if (!rootUL) return [];
const bookmarkItems = Array.from(rootUL.querySelectorAll("li.bookmark-item"));
if (bookmarkItems.length === 0) {
ztoolkit.log("No bookmarks found on this page.");
return [];
}
return bookmarkItems.map((li, index) => {
const bookmarkDiv = (li as Element).querySelector("div.bookmark-node")!;
const titleSpan = (li as Element).querySelector("span.bookmark-title")!;
return {
id: bookmarkDiv.getAttribute("data-id")!,
title: titleSpan.textContent!,
page: parseInt(bookmarkDiv.getAttribute("page")!),
x: parseFloat(bookmarkDiv.getAttribute("x")!),
y: parseFloat(bookmarkDiv.getAttribute("y")!),
order: index,
createdAt: parseInt(bookmarkDiv.getAttribute("data-created") || "0"),
color:
bookmarkDiv.getAttribute("data-color") || DEFAULT_BOOKMARK_COLORS[0],
};
});
}
export function createBookmarkNodes(
nodes: BookmarkNode[] | null,
parentElement: HTMLElement,
doc: Document,
) {
if (nodes === null || nodes.length == 0) {
ztoolkit.UI.appendElement(
{
tag: "div",
namespace: "html",
classList: ["empty-bookmark-prompt"],
properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },
},
parentElement,
);
} else {
// 按order排序
const sortedNodes = [...nodes].sort((a, b) => a.order - b.order);
sortedNodes.forEach((node) => {
const li = ztoolkit.UI.createElement(doc, "li", {
namespace: "html",
classList: ["bookmark-item"],
children: [
{
tag: "div",
namespace: "html",
classList: ["bookmark-node"],
attributes: {
draggable: "true",
"data-id": node.id,
page: node.page,
x: node.x,
y: node.y,
"data-created": node.createdAt,
"data-color": node.color,
},
styles: {
borderLeftColor: node.color,
},
children: [
{
tag: "div",
namespace: "html",
classList: ["bookmark-content"],
children: [
{
tag: "span",
namespace: "html",
classList: ["bookmark-title"],
properties: { textContent: node.title },
attributes: {
title: `${node.title}, Page: ${node.page}`,
},
},
],
},
],
},
],
});
parentElement.appendChild(li);
});
}
}
// 生成智能书签名称
function generateSmartBookmarkTitle(pageNumber: number): string {
const existingBookmarks = getBookmarksFromPage();
const baseName = `P_${pageNumber}_`;
// 检查是否有重名
const existingTitles = existingBookmarks.map((b) => b.title);
// 找到下一个可用的数字后缀
let counter = 1;
let candidateName = `${baseName}${counter}`;
while (existingTitles.includes(candidateName)) {
counter++;
candidateName = `${baseName}${counter}`;
}
return candidateName;
}
export function addNewBookmark(title?: string): BookmarkNode {
const location = getReaderPagePosition();
const now = Date.now();
const pageNumber = location.position.pageIndex + 1;
return {
id: `bookmark_${now}_${Math.random().toString(36).substr(2, 9)}`,
title: title || generateSmartBookmarkTitle(pageNumber),
page: pageNumber,
x: location.position.rects[0][0],
y: location.position.rects[0][1],
order: now, // 使用时间戳作为默认排序
createdAt: now,
color: getRandomBookmarkColor(),
};
}
export function addBookmarkButton(doc: Document) {
if (doc.querySelector("#sidebarContainer div.start") === null) {
ztoolkit.log("Sidebar toolbar button is missing.");
}
ztoolkit.UI.appendElement(
{
tag: "button",
namespace: "html",
id: "j-bookmark-button",
classList: ["toolbar-button"],
properties: { innerHTML: ICONS.bookmark },
attributes: {
title: getString("bookmark"),
tabindex: "-1",
role: "tab",
"aria-selected": "false",
"aria-controls": "j-bookmark-viewer",
},
},
doc.querySelector("#sidebarContainer div.start")!,
);
}
// Update bookmark font size dynamically
export function updateBookmarkFontSize(doc: Document, baseFontSize: number) {
const styleId = "jasminum-bookmark-dynamic-font-size";
let styleElement = doc.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = doc.createElement("style");
styleElement.id = styleId;
styleElement.type = "text/css";
doc.querySelector("head")!.appendChild(styleElement);
}
const dynamicCSS = `
.bookmark-node {
font-size: ${baseFontSize}px !important;
}
`;
styleElement.textContent = dynamicCSS;
ztoolkit.log(`Updated bookmark font size: ${baseFontSize}px`);
}
================================================
FILE: src/modules/outline/events.ts
================================================
import {
saveOutlineToJSON,
createTreeNodes,
getOutlineFromPDF,
updateOutlineFontSize,
loadOutlineInfoFromJSON,
DEFAULT_BASE_FONT_SIZE,
} from "./outline";
import {
saveBookmarksToJSON,
createBookmarkNodes,
addNewBookmark,
DEFAULT_BOOKMARK_COLORS,
updateBookmarkFontSize,
loadBookmarkInfoFromJSON,
DEFAULT_BOOKMARK_FONT_SIZE,
} from "./bookmark";
import { ICONS } from "./style";
import { getString } from "../../utils/locale";
import { getPref } from "../../utils/prefs";
const MAX_LEVEL = 7;
function getReaderPagePosition(): PdfPosition {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const primaryView = reader._internalReader
._primaryView as _ZoteroTypes.Reader.PDFView;
const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication;
const doc = primaryView._iframeWindow!.document;
const container = doc.getElementById("viewerContainer")!;
const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1;
const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex);
const viewport = pageView.viewport;
// const scrollX = container.scrollLeft - pageView.div.offsetLeft;
const scrollX = 0;
const scrollY = container.scrollTop - pageView.div.offsetTop;
const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY);
ztoolkit.log(
"get position",
pageIndex + 1,
container.scrollTop,
scrollX,
scrollY,
x,
y,
);
return { position: { pageIndex, rects: [[x, y, x, y]] } };
}
export function initEventListener(
reader: _ZoteroTypes.ReaderInstance,
doc: Document,
) {
// Hide or show side bar
function hideShowMyOutlineAndBar(e: Event) {
const targetElement = e.target as Element;
const button = targetElement.closest("button");
if (!button) return;
ztoolkit.log("click to hide outline/bookmark", targetElement, button);
// Enable j outline view
if (button.id === "j-outline-button") {
ztoolkit.log("jasminum show outline");
reader.setSidebarView("jasminum-outline");
doc.getElementById("jasminum-outline")?.classList.remove("hidden");
doc.getElementById("jasminum-bookmarks")?.classList.add("hidden");
doc
.getElementById("j-outline-toolbar")
?.classList.toggle("j-hidden", false);
doc
.getElementById("j-bookmark-toolbar")
?.classList.toggle("j-hidden", true);
button.classList.toggle("active", true);
doc
.getElementById("j-bookmark-button")
?.classList.toggle("active", false);
} else if (button.id === "j-bookmark-button") {
ztoolkit.log("jasminum show bookmark");
reader.setSidebarView("jasminum-bookmarks");
doc.getElementById("jasminum-bookmarks")?.classList.remove("hidden");
doc.getElementById("jasminum-outline")?.classList.add("hidden");
doc
.getElementById("j-bookmark-toolbar")
?.classList.toggle("j-hidden", false);
doc
.getElementById("j-outline-toolbar")
?.classList.toggle("j-hidden", true);
button.classList.toggle("active", true);
doc.getElementById("j-outline-button")?.classList.toggle("active", false);
} else {
// Hide both outline and bookmark views
ztoolkit.log("hide jasminum views");
doc.getElementById("jasminum-outline")?.classList.toggle("hidden", true);
doc
.getElementById("jasminum-bookmarks")
?.classList.toggle("hidden", true);
doc
.getElementById("j-outline-toolbar")
?.classList.toggle("j-hidden", true);
doc
.getElementById("j-bookmark-toolbar")
?.classList.toggle("j-hidden", true);
doc.getElementById("j-outline-button")?.classList.toggle("active", false);
doc
.getElementById("j-bookmark-button")
?.classList.toggle("active", false);
}
}
// 给默认按钮添加事件,避免切换面板时异常
doc
.querySelector("#sidebarContainer > div.sidebar-toolbar > div.start")
?.addEventListener("click", hideShowMyOutlineAndBar);
const treeContainer = doc.getElementById("j-outline-viewer");
if (!treeContainer) return;
// 节点展开/折叠事件,选中节点
treeContainer // 节点点击选择事件
.addEventListener("click", async (e: Event) => {
const target = e.target as HTMLElement;
ztoolkit.log("click container", e.target);
// 检查是否点击的是展开/折叠图标
const spanElement = target.closest("span");
if (spanElement && spanElement.classList.contains("expander")) {
ztoolkit.log("click expander");
const listItem = target.closest("li");
if (!listItem) return;
toggleNode(listItem);
e.stopPropagation();
await saveOutlineToJSON();
return;
}
// 节点选择
if (target.closest(".tree-node")) {
selectNode(target.closest(".tree-node")!);
clickToPosition(target);
}
});
// 双击编辑节点
treeContainer.addEventListener("dblclick", function (e) {
if ((e.target as Element).classList.contains("node-title")) {
makeNodeEditable(e.target as Element);
e.stopPropagation();
}
});
// 书签上方工具栏事件
doc
.getElementById("j-outline-expand-all")
?.addEventListener("click", expandAll);
doc
.getElementById("j-outline-collapse-all")
?.addEventListener("click", collapseAll);
doc
.getElementById("j-outline-add-node")
?.addEventListener("click", addNewNode);
doc
.getElementById("j-outline-delete-node")
?.addEventListener("click", deleteSelectedNode);
doc
.getElementById("j-outline-save-pdf")
?.addEventListener("click", async (ev: Event) => {
const button = ev.currentTarget as HTMLButtonElement;
button.disabled = true;
await addOutlineToPDFRunner();
button.disabled = false;
});
// 拖拽相关事件
treeContainer.addEventListener("dragstart", handleDragStart);
treeContainer.addEventListener("dragover", handleDragOver);
treeContainer.addEventListener("dragleave", handleDragLeave);
treeContainer.addEventListener("drop", handleDrop);
treeContainer.addEventListener("dragend", handleDragEnd);
// 处理键盘事件
treeContainer.addEventListener("keydown", handleKeydownEvent);
// 点击书签跳转到具体页码
// 书签相关事件处理
const bookmarkContainer = doc.getElementById("j-bookmark-viewer");
if (bookmarkContainer) {
// 书签点击选择和跳转事件
bookmarkContainer.addEventListener("click", async (e: Event) => {
const target = e.target as HTMLElement;
ztoolkit.log("click bookmark container", e.target);
// 书签选择和跳转
if (target.closest(".bookmark-node")) {
selectBookmarkNode(target.closest(".bookmark-node")!);
clickToBookmarkPosition(target);
}
});
// 双击编辑书签
bookmarkContainer.addEventListener("dblclick", function (e) {
if ((e.target as Element).classList.contains("bookmark-title")) {
makeBookmarkNodeEditable(e.target as Element);
e.stopPropagation();
}
});
// 书签拖拽相关事件
bookmarkContainer.addEventListener("dragstart", handleBookmarkDragStart);
bookmarkContainer.addEventListener("dragover", handleBookmarkDragOver);
bookmarkContainer.addEventListener("dragleave", handleBookmarkDragLeave);
bookmarkContainer.addEventListener("drop", handleBookmarkDrop);
bookmarkContainer.addEventListener("dragend", handleBookmarkDragEnd);
}
// 书签工具栏事件
doc
.getElementById("j-bookmark-add")
?.addEventListener("click", addNewBookmarkNode);
doc
.getElementById("j-bookmark-delete")
?.addEventListener("click", deleteSelectedBookmarkNode);
// 字体大小调整按钮事件
doc
.getElementById("j-outline-zoom-in")
?.addEventListener("click", handleFontSizeIncrease);
doc
.getElementById("j-outline-zoom-out")
?.addEventListener("click", handleFontSizeDecrease);
}
// 为节点添加事件监听,以下为事件处理函数
async function expandAll(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const collapsedNodes = doc.querySelectorAll(".tree-item.collapsed");
collapsedNodes.forEach((node) => {
node.classList.remove("collapsed");
const expander = node.querySelector(".expander");
if (expander?.hasChildNodes()) {
//expander!.textContent = "▼";
expander!.innerHTML = ICONS.down;
}
});
await saveOutlineToJSON();
}
async function collapseAll(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const parentNodes = doc.querySelectorAll(".tree-item.has-children");
parentNodes.forEach((node) => {
node.classList.add("collapsed");
const expander = node.querySelector(".expander");
if (expander?.hasChildNodes()) {
//expander!.textContent = "►";
expander!.innerHTML = ICONS.right;
}
});
await saveOutlineToJSON();
}
// 切换节点展开/折叠状态
function toggleNode(node: Element) {
if (node.classList.contains("has-children")) {
node.classList.toggle("collapsed");
// 更新展开/折叠图标
const expander = node.querySelector(".expander");
if (node.classList.contains("collapsed")) {
//expander!.textContent = "►";
expander!.innerHTML = ICONS.right;
} else {
//expander!.textContent = "▼";
expander!.innerHTML = ICONS.down;
}
}
}
// 选择节点
function selectNode(node: Element) {
const doc = node.ownerDocument;
const selectedNode = doc.querySelector(".node-selected");
// 取消之前的选择
if (selectedNode) {
selectedNode.classList.remove("node-selected");
}
// 设置新选择
node.classList.add("node-selected");
}
// Key events for the outline panel.
export async function handleKeydownEvent(ev: KeyboardEvent) {
const newPanel = (ev.target! as Element).ownerDocument.getElementById(
"root-list",
)!;
const nodes = Array.from(newPanel.querySelectorAll("div.tree-node"));
const selectedNode = newPanel.querySelector("div.tree-node.node-selected");
let currentIdx = nodes.indexOf(selectedNode as Element);
// ztoolkit.log("Keydown event", currentIdx, ev);
if (ev.key === "ArrowDown") {
while (currentIdx < nodes.length - 1) {
const nextNode = nodes[currentIdx + 1] as HTMLElement;
// ztoolkit.log("Next node", currentIdx, nextNode);
if (nextNode && nextNode.checkVisibility()) {
nextNode.querySelector("span.node-title")!.click();
nextNode.focus();
break;
}
currentIdx += 1;
}
}
if (ev.key === "ArrowUp") {
while (currentIdx > 0) {
const nextNode = nodes[currentIdx - 1] as HTMLElement;
if (nextNode && nextNode.checkVisibility()) {
nextNode.querySelector("span.node-title")!.click();
nextNode.focus();
break;
}
currentIdx -= 1;
}
}
if (ev.key === "ArrowLeft" || ev.key === "ArrowRight") {
(selectedNode?.querySelector("span.expander") as HTMLElement).click();
}
if (ev.key === " ") {
// ztoolkit.log("Space key pressed", selectedNode);
ev.preventDefault();
makeNodeEditable(
selectedNode!.querySelector("span.node-title")!,
);
}
if (ev.key === "Delete" || ev.key === "Backspace") {
// ztoolkit.log("Delete key pressed");
deleteSelectedNode(ev);
}
// Level up
if (ev.key === "[") {
// ztoolkit.log("[ key pressed");
const targetNode = (ev.target as Element).querySelector(
".node-selected",
)!;
const targetLi = targetNode.closest("li")!;
const oldParentUl = targetLi.parentElement!;
const oldGrandParent = oldParentUl.parentElement!;
// 如果是根节点,直接返回
if (oldParentUl.id === "root-list") return;
oldParentUl.removeChild(targetLi);
// 此时原来的父节点已经没有子节点了,删除
if (oldParentUl.children.length === 0) {
oldGrandParent.removeChild(oldParentUl);
oldGrandParent.classList.remove("has-children");
const expander = oldGrandParent.querySelector(".expander")!;
expander.textContent = " ";
}
oldGrandParent.parentElement!.insertBefore(
targetLi,
oldGrandParent.nextSibling,
);
updateNodeLevels(targetLi);
await saveOutlineToJSON();
}
// Level down
if (ev.key === "]") {
// ztoolkit.log("] key pressed");
const targetNode = (ev.target as Element).querySelector(
".node-selected",
)!;
const targetLi = targetNode.closest("li")!;
const parentLi = targetLi.previousElementSibling;
if (!parentLi) return;
let parentUl = parentLi.querySelector("ul");
// 如果没有子列表,创建一个
if (!parentUl) {
parentUl = targetNode.ownerDocument.createElement("ul");
parentUl.classList.add("tree-list");
parentLi.appendChild(parentUl);
// 更新父节点状态
parentLi.classList.add("has-children");
const expander = parentLi.querySelector(".expander")!;
// expander.textContent = "▼";
expander.innerHTML = ICONS.down;
}
// 添加到子列表
parentUl.appendChild(targetLi);
// 确保目标节点展开
targetLi.classList.remove("collapsed");
updateNodeLevels(targetLi);
await saveOutlineToJSON();
}
// Add new node
if (ev.key === "\\") {
// ztoolkit.log("\\ key pressed");
addNewNode(ev);
}
}
export function handleDragStart(e: DragEvent) {
// if (!(e.target instanceof HTMLElement)) return;
ztoolkit.log(" start to drag");
const target = e.target as Element;
if (!target.classList.contains("tree-node")) return;
const draggedNode = target.closest("li") as HTMLElement;
e.dataTransfer!.setData("text/plain", draggedNode.innerText);
e.dataTransfer!.effectAllowed = "move";
// 为拖拽中的元素添加样式
setTimeout(() => {
draggedNode.classList.add("dragging");
}, 0);
}
// 拖拽经过目标元素
export function handleDragOver(e: DragEvent) {
e.preventDefault();
e.dataTransfer!.dropEffect = "move";
const target = e.target as HTMLElement;
const doc = target.ownerDocument;
// 修复坐标异常
const upperHeight =
doc.querySelector("html")?.getBoundingClientRect().height || 41;
const draggedNode = doc.querySelector(".dragging");
if (!draggedNode) return;
// if (!(e.target instanceof HTMLElement)) return;
// 找到最近的节点元素
const targetNode = target.closest(".tree-node");
if (!targetNode) {
hideDropIndicator(doc);
return;
}
// 不能拖拽到自己或自己的子元素
const targetLi = targetNode.closest("li") as Element;
if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) {
hideDropIndicator(doc);
return;
}
// 计算拖拽位置(上方、中间放入其中、下方)
const rect = targetNode.getBoundingClientRect();
const mouseY = e.clientY;
const relativeY = mouseY - rect.top;
const height = rect.height;
let dropPosition;
if (relativeY < height * 0.25) {
dropPosition = "before";
} else if (relativeY > height * 0.75) {
dropPosition = "after";
} else {
dropPosition = "inside";
}
// 如果位置或目标变化了,才更新指示器
// 临时数据暂时存储在window中
if (
doc.defaultView!.lastDropPosition !== dropPosition ||
doc.defaultView!.lastDropTarget !== targetLi
) {
updateDropIndicator(targetNode, dropPosition, upperHeight);
doc.defaultView!.lastDropPosition = dropPosition;
doc.defaultView!.lastDropTarget = targetLi;
}
// 添加可放置样式
doc.querySelectorAll(".dragover").forEach((el) => {
el.classList.remove("dragover");
});
targetNode.classList.add("dragover");
}
function updateDropIndicator(
targetNode: Element,
position: string,
upperHeight: number,
) {
const rect = targetNode.getBoundingClientRect();
const doc = targetNode.ownerDocument;
const dropIndicator = doc.querySelector(".drop-indicator") as HTMLElement;
// 清除所有位置类
dropIndicator.classList.remove("top", "middle", "bottom");
dropIndicator.classList.add("visible");
if (position === "before") {
dropIndicator.classList.add("top");
dropIndicator.style.left = `${rect.left}px`;
dropIndicator.style.top = `${rect.top - 2 - upperHeight}px`;
dropIndicator.style.width = `${rect.width}px`;
} else if (position === "after") {
dropIndicator.classList.add("bottom");
dropIndicator.style.left = `${rect.left}px`;
dropIndicator.style.top = `${rect.bottom - upperHeight}px`;
dropIndicator.style.width = `${rect.width}px`;
} else {
// inside position
dropIndicator.classList.add("middle");
dropIndicator.style.left = `${rect.left + 20}px`;
dropIndicator.style.top = `${rect.top + rect.height / 2 - upperHeight}px`;
dropIndicator.style.width = `${rect.width - 25}px`;
}
}
function hideDropIndicator(doc: Document) {
const dropIndicator = doc.querySelector(".drop-indicator")!;
dropIndicator.classList.remove("visible");
doc.defaultView!.lastDropPosition = null;
doc.defaultView!.lastDropTarget = null;
}
// 拖拽离开目标元素
export function handleDragLeave(e: DragEvent) {
const doc = (e.target as Element).ownerDocument;
if (
!e.relatedTarget ||
!(e.relatedTarget as Element).closest("#j-outline-viewer")
) {
hideDropIndicator(doc);
}
const targetNode = (e.target as HTMLElement).closest(".tree-node");
if (targetNode) {
// 移除可放置样式
targetNode.classList.remove("dragover");
}
}
// 处理放置
export async function handleDrop(e: DragEvent) {
e.preventDefault();
// if (!(e.target instanceof HTMLElement)) return;
const target = e.target as HTMLElement;
const doc = target.ownerDocument;
const draggedNode = doc.querySelector(".dragging");
// 隐藏指示器
hideDropIndicator(doc);
if (!draggedNode) return;
// 获取目标节点
const targetTreeNode = target.closest(".tree-node");
if (!targetTreeNode) return;
// 移除可放置样式
doc.querySelectorAll(".dragover").forEach((el) => {
el.classList.remove("dragover");
});
// 获取目标列表项
const targetLi = targetTreeNode.closest("li")!;
// 不能将节点拖到自己或其子节点上
if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) {
return;
}
// 移除拖拽的节点
const oldParent = draggedNode.parentNode! as HTMLElement;
oldParent.removeChild(draggedNode);
// 判断放置位置:是作为子节点还是兄弟节点
const dropPosition = determineDropPosition(e, targetTreeNode);
if (dropPosition === "child") {
// 作为子节点
let targetUl = targetLi.querySelector("ul");
// 如果没有子列表,创建一个
if (!targetUl) {
targetUl = doc.createElement("ul");
targetUl.classList.add("tree-list");
targetLi.appendChild(targetUl);
// 更新父节点状态
targetLi.classList.add("has-children");
const expander = targetLi.querySelector(".expander")!;
// expander.textContent = "▼";
expander.innerHTML = ICONS.down;
}
// 确保目标节点展开
targetLi.classList.remove("collapsed");
// 添加到子列表
targetUl.appendChild(draggedNode);
} else {
// 作为兄弟节点
const targetParent = targetLi.parentNode!;
if (dropPosition === "before") {
targetParent.insertBefore(draggedNode, targetLi);
} else {
// 'after'
targetParent.insertBefore(draggedNode, targetLi.nextSibling);
}
}
// 如果原父列表为空,更新其父节点状态
if (
oldParent.children.length === 0 &&
oldParent.tagName === "UL" &&
oldParent !== doc.getElementById("root-list")
) {
const oldGrandParent = oldParent.parentNode as HTMLElement;
oldGrandParent.removeChild(oldParent);
oldGrandParent.classList.remove("has-children");
const expander = oldGrandParent.querySelector(".expander")!;
expander.textContent = " ";
}
// 更新节点级别样式
updateNodeLevels(draggedNode);
// 保存节点信息
await saveOutlineToJSON();
}
// 拖拽结束
export function handleDragEnd(e: DragEvent) {
// if (!(e.target instanceof HTMLElement)) return;
const doc = (e.target as HTMLElement).ownerDocument;
const draggedNode = doc.querySelector(".dragging");
if (!draggedNode) return;
draggedNode.classList.remove("dragging");
// 隐藏指示器
hideDropIndicator(doc);
// 清除所有dragover样式
doc.querySelectorAll(".dragover").forEach((el) => {
el.classList.remove("dragover");
});
}
// 检查一个节点是否是另一个节点的祖先
function isAncestor(ancestor: Element, descendant: Element) {
let current = descendant.parentNode;
while (current) {
if (current === ancestor) {
return true;
}
current = current.parentNode;
}
return false;
}
// 确定放置位置:作为子节点、同级前面或同级后面
function determineDropPosition(event: DragEvent, targetNode: Element) {
const rect = targetNode.getBoundingClientRect();
const mouseY = event.clientY;
// 上三分之一区域放在前面,下三分之一区域放在后面,中间放在内部
const relativeY = mouseY - rect.top;
const height = rect.height;
if (relativeY < height / 3) {
return "before";
} else if (relativeY > (height * 2) / 3) {
return "after";
} else {
return "child";
}
}
// 更新节点及其子节点的级别样式
function updateNodeLevels(node: Element) {
const updateLevel = (element: Element, level: number) => {
const nodeDiv = element.querySelector(".tree-node")!;
// 移除所有级别类
for (let i = 1; i <= MAX_LEVEL; i++) {
nodeDiv.classList.remove(`level-${i}`);
}
// 添加正确的级别类
nodeDiv.classList.add(`level-${level}`);
nodeDiv.setAttribute("level", level.toString());
// 递归处理子节点
const childList = element.querySelector("ul");
if (childList) {
Array.from(childList.children).forEach((child) => {
updateLevel(child, level + 1);
});
}
};
// 计算当前节点的级别
let level = 1;
let parent = node.parentNode as Element;
while (parent && parent.id !== "root-list") {
if (parent.tagName === "UL") {
level++;
}
parent = parent.parentNode as Element;
}
updateLevel(node, level);
}
export function makeNodeEditable(titleElement: Element) {
const doc = titleElement.ownerDocument;
const parent = titleElement.parentNode! as Element;
const treeNode = titleElement.closest("div.tree-node")!;
// 获取当前值
const currentTitle = titleElement.textContent || "";
const currentPage = treeNode.getAttribute("page")!;
// 创建容器
const container = doc.createElement("div");
container.style.display = "flex";
container.style.gap = "5px";
// 创建标题输入框
const titleInput = doc.createElement("input");
titleInput.type = "text";
titleInput.value = currentTitle.trim();
titleInput.placeholder = getString("outline-edit-placeholder");
// 替换原始元素
container.appendChild(titleInput);
// container.appendChild(pageInput);
parent.replaceChild(container, titleElement);
// 聚焦到标题输入框
titleInput.focus();
// 禁用拖拽功能
treeNode.setAttribute("draggable", "false");
// 保存逻辑
const saveChanges = async () => {
const newTitle = titleInput.value.trim();
// 更新原始元素
titleElement.textContent = newTitle || currentTitle;
titleElement.setAttribute("title", `${newTitle}, Page: ${currentPage}`);
treeNode.setAttribute("page", currentPage);
// 恢复 DOM 结构
parent.replaceChild(titleElement, container);
// 恢复拖拽功能
treeNode.setAttribute("draggable", "true");
// 保存节点信息
await saveOutlineToJSON();
};
// 事件处理
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
saveChanges();
doc.getElementById("j-outline-viewer")!.focus();
} else if (e.key === "Escape") {
parent.replaceChild(titleElement, container);
}
e.stopPropagation();
// 保留焦点
};
const handleBlur = (e: FocusEvent) => {
if (!container.contains(e.relatedTarget as Node)) {
saveChanges();
}
};
// 绑定事件
titleInput.addEventListener("keydown", handleKeyDown);
container.addEventListener("blur", handleBlur, true);
}
// 删除选中节点
export async function deleteSelectedNode(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const selectedNode = doc.querySelector(".node-selected")!;
const rootNode = doc.getElementById("root-list");
if (!selectedNode || !rootNode) return;
const listItem = selectedNode.closest("li")!;
const beforeSelectedLi = listItem.previousElementSibling;
const parent = listItem.parentNode as HTMLElement;
// 如果有子节点,则进行提示确认是否删除
if (listItem.classList.contains("has-children")) {
const confirmDelete = ztoolkit.getGlobal("confirm")(
getString("outline-delete-confirm"),
);
if (!confirmDelete) return;
}
// 移除节点
parent.removeChild(listItem);
// 如果父列表没有其他子元素,更新其父节点的状态
if (
parent.children.length === 0 &&
parent.tagName === "UL" &&
parent !== doc.getElementById("root-list")
) {
const parentLi = parent.parentNode as HTMLElement;
parentLi.removeChild(parent);
parentLi.classList.remove("has-children");
const expander = parentLi.querySelector(".expander")!;
expander.textContent = " ";
}
// 保存节点信息
await saveOutlineToJSON();
if (!rootNode.hasChildNodes()) {
ztoolkit.UI.appendElement(
{
tag: "div",
namespace: "html",
classList: ["empty-outline-prompt"],
properties: {
innerHTML: getString("outline-empty-prompt", {
args: { icon: ICONS.add },
}),
},
},
rootNode,
);
}
if (beforeSelectedLi) {
beforeSelectedLi
.querySelector("div.tree-node")
?.classList.add("node-selected");
} else {
parent.parentNode
?.querySelector("div.tree-node")
?.classList.add("node-selected");
}
doc.getElementById("j-outline-viewer")?.focus();
}
// 添加新节点。选中节点的子节点还是下一个同级节点
// 默认设置为添加节点的同级节点
export async function addNewNode(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const newTitle = "新书签";
const selectedNode = doc.querySelector(".node-selected");
const location = getReaderPagePosition();
// 如果没有选中节点,添加到根
if (!selectedNode) {
const rootList = doc.getElementById("root-list")!;
createTreeNodes(
[
{
level: 1,
title: newTitle,
page: location.position.pageIndex + 1,
x: location.position.rects[0][0],
y: location.position.rects[0][1],
},
],
rootList,
doc,
);
doc.querySelector(".empty-outline-prompt")?.classList.add("hidden");
} else {
// 添加为选中节点的子节点或兄弟节点
let targetChildrenList: HTMLElement;
let targetLevel: number;
const selectedLevel = parseInt(selectedNode.getAttribute("level") || "1");
if (getPref("newNodeAsChild")) {
// 作为子节点
const selectedLi = selectedNode.closest("li.tree-item")!;
targetLevel = selectedLevel + 1;
// 检查是否有子列表,如果没有,创建一个
targetChildrenList = selectedLi.querySelector("ul")!;
if (!targetChildrenList) {
targetChildrenList = ztoolkit.UI.createElement(doc, "ul", {
classList: ["tree-list"],
});
selectedLi.appendChild(targetChildrenList);
// 添加父节点标记并更新展开图标
selectedLi.classList.add("has-children");
const expander = selectedLi.querySelector(".expander")!;
//expander.textContent = "▼";
expander.innerHTML = ICONS.down;
}
// 确保父节点展开
selectedLi.classList.remove("collapsed");
} else {
targetLevel = selectedLevel;
targetChildrenList = selectedNode.closest("ul.tree-list") as HTMLElement;
}
createTreeNodes(
[
{
level: targetLevel,
title: newTitle,
page: location.position.pageIndex + 1,
x: location.position.rects[0][0],
y: location.position.rects[0][1],
},
],
targetChildrenList,
doc,
);
}
// 保存节点信息
await saveOutlineToJSON();
}
function clickToPosition(targetElement: Element) {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const treeNode = targetElement.closest("div.tree-node");
if (!treeNode) return;
const page = parseInt(treeNode.getAttribute("page")!);
const x = parseInt(treeNode.getAttribute("x")!);
const y = parseInt(treeNode.getAttribute("y")!);
ztoolkit.log("Click to position", page, x, y);
// const location = {
// position: { pageIndex: page - 1, rects: [[x, y, x, y]] },
// };
// @ts-ignore - not typed
// reader.navigate(location);
const PDFViewerApplication = (
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow.PDFViewerApplication;
const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1);
// @ts-ignore - Not typed
const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y);
(
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.PDFViewerApplication.page = page;
const container = (
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.document.getElementById("viewerContainer")!;
ztoolkit.log(`Scroll to ${scrollX}, ${scrollY}`);
container.scrollBy(scrollX, scrollY);
}
// Use worker to add outline to PDF
export async function addOutlineToPDFRunner(): Promise {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
if (!reader) {
ztoolkit.log("No reader found");
return;
}
const outlineNodes = await getOutlineFromPDF(reader);
if (!outlineNodes) {
ztoolkit.log("No outline nodes found");
return;
}
const filePath = reader._item.getFilePath();
const worker = new Worker(
"chrome://jasminum/content/scripts/jasminum-worker.js",
);
worker.onmessage = (event) => {
// @ts-ignore - event.data is not typed
const data = event.data;
ztoolkit.log("data", data);
if (data && data.action === "addOutlineReturn") {
ztoolkit.log("Add outline to PDF return", data);
}
};
return new Promise((resolve, reject) => {
ztoolkit.log(filePath, outlineNodes);
const jobID = Zotero.Utilities.randomString();
// 消息处理器
const handler = (event: MessageEvent) => {
const data = event.data;
ztoolkit.log("Main handler", data);
// 仅处理匹配 jobID 和 action 的消息
if (data?.action !== "addOutlineReturn" || data?.jobID !== jobID) return;
worker.removeEventListener("message", handler as EventListener);
if (data.status === "success") {
resolve(data);
} else {
reject(new Error(data.error || "Unknown error"));
}
};
worker.addEventListener("message", handler as EventListener);
worker.postMessage({ action: "addOutline", jobID, filePath, outlineNodes });
});
}
// ========== 书签相关函数 ==========
// 选择书签节点
function selectBookmarkNode(node: Element) {
const doc = node.ownerDocument;
const selectedNode = doc.querySelector(".bookmark-selected");
// 取消之前的选择
if (selectedNode) {
selectedNode.classList.remove("bookmark-selected");
}
// 设置新选择
node.classList.add("bookmark-selected");
}
// 点击书签跳转到对应位置
function clickToBookmarkPosition(targetElement: Element) {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const bookmarkNode = targetElement.closest("div.bookmark-node");
if (!bookmarkNode) return;
const page = parseInt(bookmarkNode.getAttribute("page")!);
const x = parseInt(bookmarkNode.getAttribute("x")!);
const y = parseInt(bookmarkNode.getAttribute("y")!);
ztoolkit.log("Click to bookmark position", page, x, y);
const PDFViewerApplication = (
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.PDFViewerApplication;
const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1);
// @ts-ignore - Not typed
const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y);
(
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.PDFViewerApplication.page = page;
const container = (
reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.document.getElementById("viewerContainer")!;
ztoolkit.log(`Scroll to bookmark ${scrollX}, ${scrollY}`);
container.scrollBy(scrollX, scrollY);
}
// 编辑书签节点
export function makeBookmarkNodeEditable(titleElement: Element) {
const doc = titleElement.ownerDocument;
const parent = titleElement.parentNode! as Element;
const bookmarkNode = titleElement.closest("div.bookmark-node")!;
// 获取当前值
const currentTitle = titleElement.textContent || "";
const currentPage = bookmarkNode.getAttribute("page")!;
const currentColor =
bookmarkNode.getAttribute("data-color") || DEFAULT_BOOKMARK_COLORS[0];
// 创建编辑容器
const editContainer = doc.createElement("div");
editContainer.className = "bookmark-edit-container";
// 创建标题输入框
const titleInput = doc.createElement("input");
titleInput.type = "text";
titleInput.value = currentTitle.trim();
titleInput.placeholder = "书签标题";
// 创建颜色选择器容器
const colorContainer = doc.createElement("div");
colorContainer.className = "bookmark-color-picker";
let selectedColor = currentColor;
// 创建颜色选项
DEFAULT_BOOKMARK_COLORS.forEach((color) => {
const colorOption = doc.createElement("div");
colorOption.className = "bookmark-color-option";
if (color === currentColor) {
colorOption.classList.add("selected");
}
colorOption.style.backgroundColor = color;
colorOption.addEventListener("click", () => {
// 更新选中状态
colorContainer.querySelectorAll("div").forEach((opt) => {
opt.classList.remove("selected");
});
colorOption.classList.add("selected");
selectedColor = color;
// 实时更新书签的颜色显示
(bookmarkNode as HTMLElement).style.borderLeftColor = color;
bookmarkNode.setAttribute("data-color", color);
});
colorContainer.appendChild(colorOption);
});
// 创建分隔线
const separator = doc.createElement("div");
separator.className = "bookmark-edit-separator";
editContainer.appendChild(titleInput);
editContainer.appendChild(separator);
editContainer.appendChild(colorContainer);
// 替换原始元素
parent.replaceChild(editContainer, titleElement);
// 聚焦到输入框
titleInput.focus();
// 禁用拖拽功能
bookmarkNode.setAttribute("draggable", "false");
// 保存逻辑
const saveChanges = async () => {
const newTitle = titleInput.value.trim();
// 更新原始元素
titleElement.textContent = newTitle || currentTitle;
titleElement.setAttribute("title", `${newTitle}, Page: ${currentPage}`);
// 更新颜色
bookmarkNode.setAttribute("data-color", selectedColor);
(bookmarkNode as HTMLElement).style.borderLeftColor = selectedColor;
// 恢复 DOM 结构
parent.replaceChild(titleElement, editContainer);
// 恢复拖拽功能
bookmarkNode.setAttribute("draggable", "true");
// 保存书签信息
await saveBookmarksToJSON();
};
// 事件处理
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
saveChanges();
doc.getElementById("j-bookmark-viewer")!.focus();
} else if (e.key === "Escape") {
parent.replaceChild(titleElement, editContainer);
bookmarkNode.setAttribute("draggable", "true");
}
e.stopPropagation();
};
const handleBlur = (e: FocusEvent) => {
if (!editContainer.contains(e.relatedTarget as Node)) {
saveChanges();
}
};
// 绑定事件
titleInput.addEventListener("keydown", handleKeyDown);
editContainer.addEventListener("blur", handleBlur, true);
}
// 添加新书签
export async function addNewBookmarkNode(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const newBookmark = addNewBookmark();
const rootList = doc.getElementById("bookmark-root-list")!;
// 清除空提示
doc.querySelector(".empty-bookmark-prompt")?.remove();
createBookmarkNodes([newBookmark], rootList, doc);
// 保存书签信息
await saveBookmarksToJSON();
}
// 删除选中的书签
export async function deleteSelectedBookmarkNode(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const selectedNode = doc.querySelector(".bookmark-selected")!;
const rootNode = doc.getElementById("bookmark-root-list");
if (!selectedNode || !rootNode) return;
const listItem = selectedNode.closest("li")!;
const parent = listItem.parentNode as HTMLElement;
// 移除节点
parent.removeChild(listItem);
// 保存书签信息
await saveBookmarksToJSON();
// 如果没有书签了,显示提示
if (!rootNode.hasChildNodes()) {
ztoolkit.UI.appendElement(
{
tag: "div",
namespace: "html",
classList: ["empty-bookmark-prompt"],
properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },
},
rootNode,
);
}
doc.getElementById("j-bookmark-viewer")?.focus();
}
// 书签拖拽开始
export function handleBookmarkDragStart(e: DragEvent) {
ztoolkit.log("start to drag bookmark");
const target = e.target as Element;
if (!target.classList.contains("bookmark-node")) return;
const draggedNode = target.closest("li") as HTMLElement;
e.dataTransfer!.setData("text/plain", draggedNode.innerText);
e.dataTransfer!.effectAllowed = "move";
// 为拖拽中的元素添加样式
setTimeout(() => {
draggedNode.classList.add("dragging");
}, 0);
}
// 书签拖拽经过目标元素
export function handleBookmarkDragOver(e: DragEvent) {
e.preventDefault();
e.dataTransfer!.dropEffect = "move";
const target = e.target as HTMLElement;
const doc = target.ownerDocument;
const draggedNode = doc.querySelector(".dragging");
if (!draggedNode) return;
// 找到最近的书签节点元素
const targetNode = target.closest(".bookmark-node");
if (!targetNode) {
hideBookmarkDropIndicator(doc);
return;
}
// 不能拖拽到自己
const targetLi = targetNode.closest("li") as Element;
if (draggedNode === targetLi) {
hideBookmarkDropIndicator(doc);
return;
}
// 计算拖拽位置(上方或下方)
const rect = targetNode.getBoundingClientRect();
const mouseY = e.clientY;
const relativeY = mouseY - rect.top;
const height = rect.height;
let dropPosition;
if (relativeY < height * 0.5) {
dropPosition = "before";
} else {
dropPosition = "after";
}
// 更新指示器
updateBookmarkDropIndicator(targetNode, dropPosition);
// 添加可放置样式
doc.querySelectorAll(".bookmark-dragover").forEach((el) => {
el.classList.remove("bookmark-dragover");
});
targetNode.classList.add("bookmark-dragover");
}
// 更新书签拖拽指示器
function updateBookmarkDropIndicator(targetNode: Element, position: string) {
const rect = targetNode.getBoundingClientRect();
const doc = targetNode.ownerDocument;
const dropIndicator = doc.querySelector(
".bookmark-drop-indicator",
) as HTMLElement;
dropIndicator.classList.add("visible");
if (position === "before") {
dropIndicator.style.left = `${rect.left}px`;
dropIndicator.style.top = `${rect.top - 2}px`;
dropIndicator.style.width = `${rect.width}px`;
} else {
// after position
dropIndicator.style.left = `${rect.left}px`;
dropIndicator.style.top = `${rect.bottom}px`;
dropIndicator.style.width = `${rect.width}px`;
}
}
// 隐藏书签拖拽指示器
function hideBookmarkDropIndicator(doc: Document) {
const dropIndicator = doc.querySelector(".bookmark-drop-indicator")!;
dropIndicator.classList.remove("visible");
}
// 书签拖拽离开目标元素
export function handleBookmarkDragLeave(e: DragEvent) {
const doc = (e.target as Element).ownerDocument;
if (
!e.relatedTarget ||
!(e.relatedTarget as Element).closest("#j-bookmark-viewer")
) {
hideBookmarkDropIndicator(doc);
}
const targetNode = (e.target as HTMLElement).closest(".bookmark-node");
if (targetNode) {
targetNode.classList.remove("bookmark-dragover");
}
}
// 处理书签放置
export async function handleBookmarkDrop(e: DragEvent) {
e.preventDefault();
const target = e.target as HTMLElement;
const doc = target.ownerDocument;
const draggedNode = doc.querySelector(".dragging");
// 隐藏指示器
hideBookmarkDropIndicator(doc);
if (!draggedNode) return;
// 获取目标节点
const targetBookmarkNode = target.closest(".bookmark-node");
if (!targetBookmarkNode) return;
// 移除可放置样式
doc.querySelectorAll(".bookmark-dragover").forEach((el) => {
el.classList.remove("bookmark-dragover");
});
// 获取目标列表项
const targetLi = targetBookmarkNode.closest("li")!;
// 不能将节点拖到自己上
if (draggedNode === targetLi) {
return;
}
// 移除拖拽的节点
const oldParent = draggedNode.parentNode! as HTMLElement;
oldParent.removeChild(draggedNode);
// 判断放置位置
const rect = targetBookmarkNode.getBoundingClientRect();
const mouseY = e.clientY;
const relativeY = mouseY - rect.top;
const height = rect.height;
const targetParent = targetLi.parentNode!;
if (relativeY < height * 0.5) {
// 放在前面
targetParent.insertBefore(draggedNode, targetLi);
} else {
// 放在后面
targetParent.insertBefore(draggedNode, targetLi.nextSibling);
}
// 保存书签信息
await saveBookmarksToJSON();
}
// 书签拖拽结束
export function handleBookmarkDragEnd(e: DragEvent) {
const doc = (e.target as HTMLElement).ownerDocument;
const draggedNode = doc.querySelector(".dragging");
if (!draggedNode) return;
draggedNode.classList.remove("dragging");
// 隐藏指示器
hideBookmarkDropIndicator(doc);
// 清除所有dragover样式
doc.querySelectorAll(".bookmark-dragover").forEach((el) => {
el.classList.remove("bookmark-dragover");
});
}
// ========== 字体大小调整函数 ==========
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 20;
// Increase font size for both outline and bookmark
async function handleFontSizeIncrease(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
if (!reader) return;
// Get current baseFontSize for outline
const outlineInfo = await loadOutlineInfoFromJSON(reader._item);
const currentOutlineSize =
outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;
// Get current baseFontSize for bookmark
const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);
const currentBookmarkSize =
bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;
// Increase by 1, max 20
const newOutlineSize = Math.min(currentOutlineSize + 1, MAX_FONT_SIZE);
const newBookmarkSize = Math.min(currentBookmarkSize + 1, MAX_FONT_SIZE);
if (
newOutlineSize !== currentOutlineSize ||
newBookmarkSize !== currentBookmarkSize
) {
// Update CSS
updateOutlineFontSize(doc, newOutlineSize);
updateBookmarkFontSize(doc, newBookmarkSize);
// Save to JSON
await saveOutlineToJSON(reader._item, undefined, newOutlineSize);
await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize);
ztoolkit.log(
`Font size increased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`,
);
}
}
// Decrease font size for both outline and bookmark
async function handleFontSizeDecrease(ev: Event) {
const doc = (ev.target as Element).ownerDocument;
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
if (!reader) return;
// Get current baseFontSize for outline
const outlineInfo = await loadOutlineInfoFromJSON(reader._item);
const currentOutlineSize =
outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;
// Get current baseFontSize for bookmark
const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);
const currentBookmarkSize =
bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;
// Decrease by 1, min 8
const newOutlineSize = Math.max(currentOutlineSize - 1, MIN_FONT_SIZE);
const newBookmarkSize = Math.max(currentBookmarkSize - 1, MIN_FONT_SIZE);
if (
newOutlineSize !== currentOutlineSize ||
newBookmarkSize !== currentBookmarkSize
) {
// Update CSS
updateOutlineFontSize(doc, newOutlineSize);
updateBookmarkFontSize(doc, newBookmarkSize);
// Save to JSON
await saveOutlineToJSON(reader._item, undefined, newOutlineSize);
await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize);
ztoolkit.log(
`Font size decreased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`,
);
}
}
================================================
FILE: src/modules/outline/index.ts
================================================
import { wait } from "zotero-plugin-toolkit";
import { getString } from "../../utils/locale";
import { initEventListener } from "./events";
import {
addButton,
createTreeNodes,
getOutlineFromPDF,
registerOutlineCSS,
registerThemeChange,
updateOutlineFontSize,
loadOutlineInfoFromJSON,
DEFAULT_BASE_FONT_SIZE,
} from "./outline";
import {
addBookmarkButton,
createBookmarkNodes,
loadBookmarksFromJSON,
updateBookmarkFontSize,
loadBookmarkInfoFromJSON,
DEFAULT_BOOKMARK_FONT_SIZE,
} from "./bookmark";
import { ICONS } from "./style";
import { getPref } from "../../utils/prefs";
export function renderTree(
reader: _ZoteroTypes.ReaderInstance,
doc: Document,
data: OutlineNode[] | null,
) {
const dropIndicator = ztoolkit.UI.createElement(doc, "div", {
classList: ["drop-indicator"],
});
const toolbar = ztoolkit.UI.createElement(doc, "div", {
namespace: "html",
id: "j-outline-toolbar",
classList: ["j-hidden"], // 默认隐藏
children: [
{
tag: "button",
id: "j-outline-expand-all",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.expand },
attributes: { title: getString("outline-expand-all") },
},
{
tag: "button",
id: "j-outline-collapse-all",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.collapse },
attributes: { title: getString("outline-collapse-all") },
},
{
tag: "button",
id: "j-outline-add-node",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.add },
attributes: { title: getString("outline-add") },
},
{
tag: "button",
id: "j-outline-delete-node",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.del },
attributes: { title: getString("outline-delete") },
},
{
tag: "button",
id: "j-outline-save-pdf",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.save },
attributes: { title: getString("outline-save-to-pdf") },
},
],
});
const treeContainer = ztoolkit.UI.createElement(doc, "div", {
id: "jasminum-outline",
classList: ["hidden"], // 默认隐藏
namespace: "html",
children: [
{
tag: "div",
namespace: "html",
id: "j-outline-viewer",
classList: ["outline-view"],
attributes: {
tabindex: "-1",
"data-tabstop": "1",
role: "tabpanel",
"aria-labelledby": "j-outline-button",
},
children: [
{
tag: "ul",
namespace: "html",
id: "root-list",
classList: ["tree-list"],
},
],
},
{
tag: "div",
namespace: "html",
classList: ["jasminum-sidebar-bottom"],
children: [
{
tag: "button",
namespace: "html",
id: "j-outline-zoom-in",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.plus },
attributes: { title: "字体变大" },
styles: { paddingBottom: "7px" },
},
{
tag: "button",
namespace: "html",
id: "j-outline-zoom-out",
classList: ["j-outline-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.minus },
attributes: { title: "字体变小" },
styles: { paddingBottom: "7px" },
},
],
},
],
});
// 隐藏 Zotero 大纲按钮
if (getPref("disableZoteroOutline")) {
doc.getElementById("viewOutline")!.style.display = "none";
}
// 添加工具栏
doc
.getElementById("sidebarContainer")!
.insertBefore(toolbar, doc.getElementById("sidebarContent")!);
treeContainer.appendChild(dropIndicator);
createTreeNodes(data, treeContainer.querySelector("#root-list")!, doc);
doc.querySelector("#sidebarContent")?.appendChild(treeContainer);
return treeContainer;
}
export function renderBookmarkTree(
reader: _ZoteroTypes.ReaderInstance,
doc: Document,
data: BookmarkNode[] | null,
) {
const dropIndicator = ztoolkit.UI.createElement(doc, "div", {
classList: ["bookmark-drop-indicator"],
});
const toolbar = ztoolkit.UI.createElement(doc, "div", {
namespace: "html",
id: "j-bookmark-toolbar",
classList: ["j-hidden"], // 默认隐藏
children: [
{
tag: "button",
id: "j-bookmark-add",
classList: ["j-bookmark-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.add },
attributes: { title: getString("bookmark-add") },
},
{
tag: "button",
id: "j-bookmark-delete",
classList: ["j-bookmark-toolbar-button", "toolbar-button"],
properties: { innerHTML: ICONS.del },
attributes: { title: getString("bookmark-delete") },
},
],
});
const bookmarkContainer = ztoolkit.UI.createElement(doc, "div", {
id: "jasminum-bookmarks",
classList: ["hidden"], // 默认隐藏
namespace: "html",
children: [
{
tag: "div",
namespace: "html",
id: "j-bookmark-viewer",
classList: ["bookmark-view"],
attributes: {
tabindex: "-1",
"data-tabstop": "1",
role: "tabpanel",
"aria-labelledby": "j-bookmark-button",
},
children: [
{
tag: "ul",
namespace: "html",
id: "bookmark-root-list",
classList: ["bookmark-list"],
},
],
},
],
});
// 添加工具栏
doc
.getElementById("sidebarContainer")!
.insertBefore(toolbar, doc.getElementById("sidebarContent")!);
bookmarkContainer.appendChild(dropIndicator);
createBookmarkNodes(
data,
bookmarkContainer.querySelector("#bookmark-root-list")!,
doc,
);
doc.querySelector("#sidebarContent")?.appendChild(bookmarkContainer);
return bookmarkContainer;
}
export async function addOutlineToReader(reader: _ZoteroTypes.ReaderInstance) {
const doc = reader._iframeWindow!.document;
if (doc.querySelector("#j-outline-button")) {
ztoolkit.log("Outline is already added, skip.");
return;
}
// 等待元素加载
await wait.waitUtilAsync(
() => {
return doc.querySelector("#sidebarContainer div.start") ? true : false;
},
5, // 减少图标出现延迟感
5000,
);
ztoolkit.log("Sidebar container is ready.");
addButton(doc);
addBookmarkButton(doc); // 同时添加书签按钮
const joutline = await getOutlineFromPDF(reader);
if (!joutline) {
ztoolkit.log("No outline to add.");
}
ztoolkit.log("++joutline", joutline);
const bookmarks = await loadBookmarksFromJSON(reader._item);
ztoolkit.log("++bookmarks", bookmarks);
// Load baseFontSize from JSON for outline
const outlineInfo = await loadOutlineInfoFromJSON(reader._item);
const outlineBaseFontSize =
outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;
// Load baseFontSize from JSON for bookmark
const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);
const bookmarkBaseFontSize =
bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;
registerOutlineCSS(doc);
registerThemeChange(reader._iframeWindow!);
// Apply dynamic font size for both outline and bookmark
updateOutlineFontSize(doc, outlineBaseFontSize);
updateBookmarkFontSize(doc, bookmarkBaseFontSize);
renderTree(reader, doc, joutline);
renderBookmarkTree(reader, doc, bookmarks);
initEventListener(reader, doc);
}
export async function registerOutline(tabID: string) {
if (!tabID) {
ztoolkit.log(`Tab ID is not valid. %{tabID}`);
return;
}
await Zotero.Reader.init();
const reader = Zotero.Reader.getByTabID(tabID as string);
try {
await reader._initPromise;
ztoolkit.log("Init " + reader._isReaderInitialized);
// Only pdf
if (reader._item.attachmentContentType != "application/pdf") {
ztoolkit.log("Only support PDF reader.");
return;
}
// This should add a waiting process.
// @ts-ignore - not typed
const doc = reader._iframeWindow?.document;
// ztoolkit.log("registerOutline", new Date().toISOString());
await wait.waitUtilAsync(
() => {
return doc && doc.getElementById("sidebarToggle") ? true : false;
},
5,
5000,
);
// ztoolkit.log("registerOutline", new Date().toISOString());
ztoolkit.log("Sidebar toggle button is ready.");
// Sidebar is already opened, add outline.
if (doc && doc.getElementById("sidebarContainer")) {
addOutlineToReader(reader);
}
// Click toggle button to open sidebar.
doc
?.getElementById("sidebarToggle")
?.addEventListener("click", (ev: Event) => {
ztoolkit.log("outline is added by toggle click");
addOutlineToReader(reader);
});
} catch (e) {
Zotero.debug(
"********************* outline add error *********************",
);
ztoolkit.log("Error in registerOutline", e);
ztoolkit.log(`tabID: ${tabID}`);
ztoolkit.log(`reader: ${reader}`);
}
}
================================================
FILE: src/modules/outline/outline.ts
================================================
import { wait } from "zotero-plugin-toolkit";
import { version } from "../../../package.json";
import { getString } from "../../utils/locale";
import { outline_css, ICONS } from "./style";
// 2 : Add base font size = 12
export const OUTLINE_SCHEMA = 2;
export const DEFAULT_BASE_FONT_SIZE = 12; // Default base font size for level-1
// Register custom CSS for Jasminum outline
export function registerOutlineCSS(doc: Document) {
ztoolkit.log("** Register css");
ztoolkit.UI.appendElement(
{
tag: "style",
namespace: "html",
attributes: { type: "text/css" },
properties: {
textContent: outline_css,
},
},
doc.querySelector("head")!,
);
}
// Update font size dynamically based on baseFontSize
export function updateOutlineFontSize(doc: Document, baseFontSize: number) {
const styleId = "jasminum-dynamic-font-size";
let styleElement = doc.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = doc.createElement("style");
styleElement.id = styleId;
styleElement.type = "text/css";
doc.querySelector("head")!.appendChild(styleElement);
}
// Calculate font sizes: level-1 = base, level-2 = base-1, level-3+ = base-2
const level1Size = baseFontSize;
const level2Size = baseFontSize - 1;
const level3PlusSize = baseFontSize - 2;
const dynamicCSS = `
.level-1 { font-size: ${level1Size}px !important; }
.level-2 { font-size: ${level2Size}px !important; }
.level-3, .level-4, .level-5, .level-6, .level-7 {
font-size: ${level3PlusSize}px !important;
}
`;
styleElement.textContent = dynamicCSS;
ztoolkit.log(`Updated font size: base=${baseFontSize}px`);
}
// Register for theme update
export function registerThemeChange(win: Window) {
win
?.matchMedia("(prefers-color-scheme: dark)")!
.addEventListener("change", (e: MediaQueryListEvent) => {
if (e.matches) {
win.document.documentElement.setAttribute("data-theme", "dark");
} else {
win.document.documentElement.setAttribute("data-theme", "light");
}
});
// Init theme for outline tree.
// 窗口启动时为黑暗主题,将书签主题设置为黑暗模式
if (win.matchMedia("(prefers-color-scheme: dark)")!.matches === true) {
win.document.documentElement.setAttribute("data-theme", "dark");
}
}
// Add outline button and outline tree.
export function addButton(doc: Document) {
if (doc.querySelector("#sidebarContainer div.start") === null) {
ztoolkit.log("Sidebar toolbar button is missing.");
}
ztoolkit.UI.appendElement(
{
tag: "button",
namespace: "html",
id: "j-outline-button",
classList: ["toolbar-button"],
properties: { innerHTML: ICONS.outline },
attributes: {
title: getString("outline"),
tabindex: "-1",
role: "tab",
"aria-selected": "false",
"aria-controls": "j-outline-viewer",
},
// listeners: [
// {
// type: "click",
// listener: (e) => {
// ztoolkit.log("Button.click");
// ztoolkit.log(e);
// const d = (e.target! as HTMLButtonElement).ownerDocument;
// const viewer = d.getElementById("j-outline-viewer")?.parentElement;
// // 显示工具栏
// d
// .getElementById("j-outline-toolbar")
// ?.classList.toggle("j-outline-hidden", false);
// if (!viewer?.classList.contains("hidden")) {
// ztoolkit.log("Already display");
// } else {
// // 按钮的激活状态
// d
// .getElementById("viewThumbnail")
// ?.classList.toggle("active", false);
// d
// .getElementById("viewOutline")
// ?.classList.toggle("active", false);
// d
// .getElementById("viewAnnotations")
// ?.classList.toggle("active", false);
// d
// .getElementById("j-outline-button")
// ?.classList.toggle("active", true);
// // 书签内容显示
// d
// .getElementById("thumbnailsView")
// ?.parentElement?.classList.toggle("hidden", true);
// d
// .getElementById("annotationsView")
// ?.classList.toggle("hidden", true);
// d
// .getElementById("outlineView")
// ?.parentElement?.classList.toggle("hidden", true);
// viewer?.classList.toggle("hidden", false);
// ztoolkit.log("Display jasminum outline.");
// }
// },
// },
// ],
},
doc.querySelector("#sidebarContainer div.start")!,
);
}
// 有 JSON 文件优先读取JSON文件
// 然后再获取PDF自带书签
export async function getOutlineFromPDF(
reader: _ZoteroTypes.ReaderInstance,
): Promise {
const item = reader._item;
// 优先从JSON缓存中读取书签信息
const outlineJson = await loadOutlineFromJSON(item);
if (outlineJson) return outlineJson;
// 如果上面没有返回Outline信息,重新读取
await wait.waitUtilAsync(
() => {
return (reader._primaryView as _ZoteroTypes.Reader.PDFView)
._iframeWindow &&
(reader._primaryView as _ZoteroTypes.Reader.PDFView)._iframeWindow!
.PDFViewerApplication.pdfDocument
? true
: false;
},
200,
5000,
);
ztoolkit.log("PDFViewerApplication is ready");
const PDFViewerApplication = (
reader._primaryView as _ZoteroTypes.Reader.PDFView
)._iframeWindow!.PDFViewerApplication;
await PDFViewerApplication.init;
const pdfDocument = PDFViewerApplication.pdfDocument;
if (!pdfDocument) {
ztoolkit.log("No pdfDocument");
return null;
}
// @ts-ignore - Not typed
const originOutline: PdfOutlineNode[] = await pdfDocument.getOutline2();
if (originOutline.length == 0) return null;
ztoolkit.log(originOutline);
async function convert(
node: PdfOutlineNode,
level = 0,
): Promise {
level += 1;
const title = node.title;
// Default position
const outlineNode: OutlineNode = {
level,
title,
page: 1,
x: 100,
y: 100,
children: [],
};
// Some pdf missing dest, position instead.
if (node.location && "dest" in node.location) {
// @ts-ignore - Not typed
const page = await pdfDocument.getPageIndex(node.location.dest);
outlineNode.page = page;
} else if (node.location && "position" in node.location) {
outlineNode.page = node.location.position.pageIndex + 1;
outlineNode.x = node.location.position.rects[0][0];
outlineNode.y = node.location.position.rects[0][1];
}
if (node.items.length > 0) {
outlineNode.children = await Promise.all(
node.items.map((n) => convert(n, level)),
);
}
return outlineNode;
}
const outline = await Promise.all(
originOutline.map((node) => convert(node, 0)),
);
await saveOutlineToJSON(item, outline);
return outline;
}
export function getOutlineFromPage(): OutlineNode[] {
function loop(ul: Element): OutlineNode[] {
const lis = Array.from(
ul.querySelectorAll(":scope > li.tree-item"),
)! as Element[];
return lis.map((li) => {
const titleSpan = li.querySelector("span.node-title")!;
const nodeDiv = li.querySelector("div.tree-node")!;
return {
level: parseInt(nodeDiv.getAttribute("level")!),
title: titleSpan.textContent!,
page: parseInt(nodeDiv.getAttribute("page")!),
x: parseFloat(nodeDiv.getAttribute("x")!),
y: parseFloat(nodeDiv.getAttribute("y")!),
children: li.classList.contains("has-children")
? loop(li.querySelector("ul")!)
: [],
collapsed: li.classList.contains("collapsed"),
};
});
}
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
const rootUL = reader._iframeWindow!.document.querySelector("#root-list ");
if (!rootUL) return [];
return loop(rootUL);
}
// 注意SCHEMA
// 注意打开PDF时,默认打开书签
export async function saveOutlineToJSON(
item?: Zotero.Item,
outline?: OutlineNode[],
baseFontSize?: number,
) {
if (!outline) {
outline = getOutlineFromPage();
}
if (!item) {
const reader = Zotero.Reader.getByTabID(
ztoolkit.getGlobal("Zotero_Tabs").selectedID,
);
item = reader._item;
}
// Get current baseFontSize if not provided
if (baseFontSize === undefined) {
const currentInfo = await loadOutlineInfoFromJSON(item);
baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;
}
const outlineInfo: OutlineInfo = {
info: {
itemID: item.id,
schema: OUTLINE_SCHEMA,
jasminumVersion: version,
baseFontSize: baseFontSize,
},
outline: outline,
};
const outlineStr = JSON.stringify(outlineInfo);
const outlinePath = PathUtils.join(
Zotero.DataDirectory.dir,
"storage",
item.key,
"jasminum-outline.json",
);
await Zotero.File.putContentsAsync(outlinePath, outlineStr);
ztoolkit.log("Save outline to JSON");
}
function migrateOutlineInfo(
raw: any,
fromSchema: number,
): { outline: OutlineNode[]; baseFontSize: number } {
let outline: OutlineNode[] = raw.outline ?? [];
let baseFontSize = DEFAULT_BASE_FONT_SIZE;
// v1 → v2: add baseFontSize
if (fromSchema < 2) {
baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;
}
// Future v2 → v3 migrations go here
return { outline, baseFontSize };
}
// 加载时要考虑JSON文件的版本信息,如果版本低,要重新从原文件加载信息
export async function loadOutlineInfoFromJSON(
item: Zotero.Item,
): Promise<{ outline: OutlineNode[]; baseFontSize: number } | null> {
const outlinePath = PathUtils.join(
Zotero.DataDirectory.dir,
"storage",
item.key,
"jasminum-outline.json",
);
const isFileExist = await IOUtils.exists(outlinePath);
if (!isFileExist) {
ztoolkit.log(`Outline json is missing: ${outlinePath}`);
return null;
} else {
const content = (await Zotero.File.getContentsAsync(outlinePath)) as string;
const tmp = JSON.parse(content);
const fileSchema = tmp.info?.schema ?? 1;
if (fileSchema < OUTLINE_SCHEMA) {
// Migrate old outline data instead of discarding
const migrated = migrateOutlineInfo(tmp, fileSchema);
await saveOutlineToJSON(item, migrated.outline, migrated.baseFontSize);
return migrated;
} else {
const outlineInfo = JSON.parse(content) as OutlineInfo;
return {
outline: outlineInfo.outline,
baseFontSize: outlineInfo.info.baseFontSize ?? DEFAULT_BASE_FONT_SIZE,
};
}
}
}
export async function loadOutlineFromJSON(
item: Zotero.Item,
): Promise {
const info = await loadOutlineInfoFromJSON(item);
return info?.outline ?? null;
}
export function createTreeNodes(
nodes: OutlineNode[] | null,
parentElement: HTMLElement,
doc: Document,
) {
if (nodes === null || nodes.length == 0) {
ztoolkit.UI.appendElement(
{
tag: "div",
namespace: "html",
classList: ["empty-outline-prompt"],
properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },
},
parentElement,
);
} else {
nodes.forEach((node) => {
const li = ztoolkit.UI.createElement(doc, "li", {
namespace: "html",
classList:
node.children && node.children.length > 0
? ["tree-item", "has-children"]
: ["tree-item"],
children: [
{
tag: "div",
namespace: "html",
classList: ["tree-node", `level-${node.level}`],
attributes: {
draggable: "true",
level: node.level,
x: node.x,
y: node.y,
page: node.page,
},
children: [
{
tag: "span",
namespace: "html",
classList: ["expander"],
properties: {
innerHTML:
node.children && node.children.length > 0
? node.collapsed === false
? ICONS.down
: ICONS.right
: " ",
},
},
{
tag: "div",
namespace: "html",
classList: ["node-content"],
children: [
{
tag: "span",
namespace: "html",
classList: ["node-title"],
properties: { textContent: node.title },
attributes: {
title: `${node.title}, Page: ${node.page}`,
},
},
],
},
],
},
],
});
// Collapsed node
if (node.collapsed) {
li.classList.add("collapsed");
}
// Add children node
if (node.children && node.children.length > 0) {
const ul = ztoolkit.UI.createElement(doc, "ul", {
namespace: "html",
classList: ["tree-list"],
});
createTreeNodes(node.children, ul, doc);
li.appendChild(ul);
}
// Now append the node to the parentElement.
parentElement.appendChild(li);
return li;
});
}
}
================================================
FILE: src/modules/outline/style.ts
================================================
export const ICONS = {
outline: ` `,
bookmark: ` `,
expand: ` `,
collapse: ` `,
add: ` `,
del: ` `,
save: ` `,
down: ` `,
right: ` `,
plus: ` `,
minus: ` `,
};
export const outline_css = `
:root {
/* Light mode variables */
--background-color: #f5f5f5;
--container-bg: white;
--text-color: #333;
--heading-color: #2c3e50;
--border-color: #ddd;
--button-bg: #e8f4fd;
--button-hover-bg: #e8f4fd;
--node-hover-bg: #f0f0f0;
--selected-node-bg: #e8f4fd;
--shadow-color: rgba(0, 0, 0, 0.1);
--dragover-bg: rgba(52, 152, 219, 0.1);
--drop-indicator-color: #3498db;
}
#j-outline-viewer {
max-width: 1000px;
margin: 0 auto 37px auto;
background: var(--container-bg);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 10px var(--shadow-color);
font-family: Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
padding: 2px 8px 8px 8px;
transition:
background-color 0.3s,
color 0.3s;
}
[data-theme="dark"] {
/* Dark mode variables */
--background-color: #1a1a1a;
--container-bg: #2c2c2c;
--text-color: #e0e0e0;
--heading-color: #90caf9;
--border-color: #444;
--button-bg: #2196f3;
--button-hover-bg: #1976d2;
--node-hover-bg: #3e3e3e;
--selected-node-bg: #2a4055;
--shadow-color: rgba(0, 0, 0, 0.3);
--dragover-bg: rgba(33, 150, 243, 0.2);
--drop-indicator-color: #64b5f6;
}
.j-hidden {
display: none !important;}
#j-outline-toolbar {
display: inline-flex;
gap: 6px;
padding: 4px 4px 4px 8px;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
.j-outline-toolbar-button {
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.j-outline-toolbar-button svg {
display: block;
width: 24px;
height: 24px;
}
button:hover.j-outline-toolbar-button {
background: var(--button-hover-bg);
}
.tree-container {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 15px;
background: var(--container-bg);
width: 350px;
overflow: auto;
transition:
background 0.3s,
border-color 0.3s;
}
.tree-list {
list-style-type: none;
padding-left: 0;
position: relative;
}
.tree-list li {
margin: 1px 0;
position: relative;
}
.tree-list ul {
list-style-type: none;
padding-left: 25px;
padding-top: 2px;
position: relative;
}
.tree-node {
display: flex;
align-items: center;
padding: 1px;
border-radius: 2px;
cursor: pointer;
transition:
background 0.2s,
border-left-color 0.2s;
border-left: 2px solid transparent;
border-left-width: 2px;
position: relative;
}
.tree-node:hover {
background: var(--node-hover-bg);
}
.node-selected {
background: var(--selected-node-bg);
}
.tree-node.dragging {
opacity: 0.5;
}
.dragover {
background-color: var(--dragover-bg);
}
.drop-indicator {
position: absolute;
height: 2px;
background-color: var(--drop-indicator-color);
left: 0;
right: 0;
pointer-events: none;
display: none;
transition: background-color 0.3s;
}
.drop-indicator.visible {
display: block;
}
.drop-indicator::before {
content: "";
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--drop-indicator-color);
left: -3px;
top: -2px;
transition: background-color 0.3s;
}
.drop-indicator.top {
top: 0;
}
.drop-indicator.bottom {
bottom: 0;
}
.drop-indicator.middle {
top: 50%;
box-shadow: 0 0 3px var(--shadow-color);
}
.expander {
width: 20px;
height: 20px;
cursor: pointer;
margin-right: 2px;
margin-left: -6px;
text-align: center;
line-height: 10px;
flex-shrink: 0;
}
.node-content {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
display: flex;
}
.node-title {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.node-edit {
padding: 2px;
border: 1px solid var(--border-color);
border-radius: 3px;
font-family: inherit;
font-size: inherit;
width: 100%;
background-color: var(--container-bg);
color: var(--text-color);
}
/* Rainbow hierarchy indicators for different levels - maintained in both themes */
.level-1 {
font-size: 12px;
border-left-color: #ff5252; /* Red */
}
.level-2 {
font-size: 11px;
border-left-color: #ff9800; /* Orange */
}
.level-3 {
font-size: 10px;
border-left-color: #ffeb3b; /* Yellow */
}
.level-4 {
font-size: 10px;
border-left-color: #4caf50; /* Green */
}
.level-5 {
font-size: 10px;
border-left-color: #2196f3; /* Blue */
}
.level-6 {
font-size: 10px;
border-left-color: #673ab7; /* Purple */
}
.level-7 {
font-size: 10px;
border-left-color: #e91e63; /* Pink */
}
.collapsed > ul {
display: none;
}
.hidden {
display: none
}
/* 书签相关样式 */
#j-bookmark-toolbar {
display: inline-flex;
gap: 6px;
padding: 4px 4px 4px 8px;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
.j-bookmark-toolbar-button {
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.j-bookmark-toolbar-button svg {
display: block;
width: 24px;
height: 24px;
}
button:hover.j-bookmark-toolbar-button {
background: var(--button-hover-bg);
}
#j-bookmark-viewer {
max-width: 1000px;
margin: 0 auto 37px auto;
background: var(--container-bg);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 10px var(--shadow-color);
font-family: Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
padding: 2px 8px 8px 8px;
transition:
background-color 0.3s,
color 0.3s;
}
.bookmark-list {
list-style-type: none;
padding-left: 0;
position: relative;
}
.bookmark-list li {
margin: 2px 0;
position: relative;
}
.bookmark-node {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition:
background 0.2s,
border-left-color 0.2s;
border-left: 3px solid #3498db;
position: relative;
font-size: 13px;
}
.bookmark-node:hover {
background: var(--node-hover-bg);
}
.bookmark-selected {
background: var(--selected-node-bg);
}
.bookmark-node.dragging {
opacity: 0.5;
}
.bookmark-dragover {
background-color: var(--dragover-bg);
}
.bookmark-drop-indicator {
position: absolute;
height: 2px;
background-color: var(--drop-indicator-color);
left: 0;
right: 0;
pointer-events: none;
display: none;
transition: background-color 0.3s;
z-index: 1000;
}
.bookmark-drop-indicator.visible {
display: block;
}
.bookmark-drop-indicator::before {
content: "";
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--drop-indicator-color);
left: -3px;
top: -2px;
transition: background-color 0.3s;
}
.bookmark-content {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
display: flex;
}
.bookmark-title {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.empty-bookmark-prompt {
text-align: center;
color: #999;
font-style: italic;
padding: 20px;
border: 2px dashed #ddd;
border-radius: 4px;
margin: 10px 0;
}
/* 书签颜色选择器 */
.bookmark-color-picker {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
background: var(--bookmark-container-bg);
border: 1px solid var(--bookmark-border-color);
border-radius: 4px;
}
.bookmark-color-option {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 1px solid var(--bookmark-border-color);
transition: transform 0.2s, border 0.2s;
}
.bookmark-color-option:hover {
transform: scale(1.1);
}
.bookmark-color-option.selected {
border: 2px solid var(--bookmark-text-color);
}
/* 书签编辑容器 */
.bookmark-edit-container {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
padding: 8px;
background: var(--bookmark-container-bg);
border: 1px solid var(--bookmark-border-color);
border-radius: 4px;
}
.bookmark-edit-container input {
padding: 4px 8px;
border: 1px solid var(--bookmark-border-color);
border-radius: 3px;
background: var(--bookmark-container-bg);
color: var(--bookmark-text-color);
font-family: inherit;
font-size: inherit;
}
/* 书签编辑分隔线 */
.bookmark-edit-separator {
height: 1px;
background: linear-gradient(90deg, transparent, var(--bookmark-border-color), transparent);
margin: 4px 0;
opacity: 0.6;
}
/* 底部功能栏 */
.jasminum-sidebar-bottom {
padding: 8px 8px;
border-top: var(--material-panedivider);
z-index: 1;
height: 37px;
overflow: hidden;
position: absolute;
bottom: 0;
width: 100%;
display: flex;
gap: 8px;
background-color: var(--background-color);
}
`;
================================================
FILE: src/modules/preferences/main.ts
================================================
import { config } from "../../../package.json";
import { isMainlandChina } from "../../utils/http";
import { getString } from "../../utils/locale";
import { getPref, setPref } from "../../utils/prefs";
import { updateTranslators, bestSpeedBaseUrl } from ".././translators";
import type { PluginPrefsMap } from "../../utils/prefs";
import { onShowTable } from "./translators";
export function registerPrefsPane() {
Zotero.PreferencePanes.register({
pluginID: config.addonID,
src: `chrome://${config.addonRef}/content/preferences-main.xhtml`,
label: getString("plugin-name"),
image: `chrome://${config.addonRef}/content/icons/icon.png`,
});
}
/**
* This function is called when the prefs window is opened
See addon/chrome/content/preferences.xul onpaneload
* @param _window Preference window
*/
export async function onPrefsWindowLoad(_window: Window) {
if (!addon.data.prefs) {
addon.data.prefs = {
window: _window,
};
} else {
addon.data.prefs.window = _window;
}
updatePrefsUI(addon.data.prefs.window.document);
bindPrefEvents(addon.data.prefs.window.document);
}
/**
* Initialize platform specific preferences
*/
export async function initPrefs() {
ztoolkit.log("init some prefs");
if (addon.data.env == "development") {
setPref("firstRun", true);
}
if (getPref("firstRun")) {
// For Zotero 6
migratePrefs("extensions.zotero.jasminum.");
// For Zotero 7
migratePrefs("extensions.jasminum.");
const inMainlandChina = await isMainlandChina();
setPref("isMainlandChina", inMainlandChina);
setPref("firstRun", false);
}
if (!getPref("pdfMatchFolder")) {
setPref(
"pdfMatchFolder",
Services.dirsvc.get("DfltDwnld", Ci.nsIFile).path,
);
}
if (
!getPref("translatorSource") ||
getPref("translatorSource") ===
"https://ftp.linxingzhong.top/translators_CN"
) {
setPref("translatorSource", await bestSpeedBaseUrl());
}
const translatortUpdateTime = getPref("translatorUpdateTime");
if (
typeof translatortUpdateTime !== "string" ||
/\D/.test(translatortUpdateTime)
) {
Zotero.Prefs.clear(`${config.prefsPrefix}.translatorUpdateTime`);
setPref("translatorUpdateTime", "0");
}
}
/**
* Keep preferences startswith extensions.jasminum, clear deprecated preferences.
* This function should be called only once when updating from old version extension.
* @param prefix prefix with following dot
*/
function migratePrefs(prefix: string) {
ztoolkit.log(`migrate prefs with prefix ${prefix}`);
const acceptPrefsMap: Record = {
firstrun: "firstRun",
/* tools */
zhnamesplit: "autoSplitName",
ennamesplit: "splitEnName",
language: "language",
/* retrieve metadata */
autoupdate: "autoUpdateMetadata",
namepattern: "namePattern",
namepatternCustom: "namePatternCustom",
metadataSource: "metadataSource",
/* match pdf */
pdfMatchFolder: "pdfMatchFolder",
/* update translators */
autoUpdateTranslators: "autoUpdateTranslators",
translatorSource: "translatorSource",
};
function isPrefKey(key: string): key is keyof typeof acceptPrefsMap {
return key in acceptPrefsMap;
}
const oldPrefs = Services.prefs.getBranch(prefix).getChildList("");
for (const oldPrefKey of oldPrefs) {
const oldFullKey = `${prefix}${oldPrefKey}`;
const prefValue = Zotero.Prefs.get(oldFullKey);
if (prefValue !== undefined) {
if (isPrefKey(oldPrefKey)) {
const newPrefKey = acceptPrefsMap[oldPrefKey];
// New preference key is compatible with old preference value
setPref(newPrefKey, prefValue as PluginPrefsMap[keyof PluginPrefsMap]);
ztoolkit.log(
`Migrate preference ${oldFullKey} -> ${config.prefsPrefix}.${newPrefKey}, ${prefValue}`,
);
} else {
Zotero.Prefs.clear(oldFullKey);
}
}
}
}
/**
* Initialize UI elements on prefs window with addon.data.prefs.window.document
*/
async function updatePrefsUI(doc: Document) {
const namePatterns: Record = {
auto: 1,
"{%t}_{%g}": 2,
"{%t}": 3,
custom: 4,
};
(
doc.querySelector(
"#zotero-prefpane-jasminum-namepattern-menulist",
) as XULMenuListElement
).selectedIndex = namePatterns[getPref("namePattern")] - 1;
}
function bindPrefEvents(doc: Document) {
/* PDF file name patttern */
doc
.getElementById(`zotero-prefpane-${config.addonRef}-namepattern-menulist`)
?.addEventListener("click", (event: Event) => {
const pName = "namePattern";
const value = (event.target as XULMenuItemElement).getAttribute("value")!;
const customInput = doc.getElementById(
`zotero-prefpane-${config.addonRef}-namepatternCustom-input`,
);
const input = doc.getElementById(
`zotero-prefpane-${config.addonRef}-namepattern-input`,
);
const isCustom = value === "custom";
if (isCustom) setPref("namePattern", "custom");
customInput?.classList.toggle("hidden", !isCustom);
input?.classList.toggle("hidden", isCustom);
setPref(pName, value);
});
/* Update translators */
doc
.getElementById(`zotero-prefpane-${config.addonRef}-force-update`)
?.addEventListener("click", async (event) => {
const button = event.target as HTMLButtonElement;
button.disabled = true;
if (addon.data.translators.updating) {
ztoolkit.log("Chinese translators are under updating.");
addon.data.prefs?.window.alert(
getString("info-translators-cn-updaing"),
);
} else {
await updateTranslators(true);
}
addon.data.prefs?.window.setTimeout(() => {
button.disabled = false;
}, 3000);
});
doc
.querySelector(`#zotero-prefpane-${config.addonRef}-open-translator-table`)
?.addEventListener("click", async (event) => {
onShowTable();
});
doc
.getElementById(`zotero-prefpane-${config.addonRef}-best-speed-button`)
?.addEventListener("click", async (event) => {
const button = event.target as HTMLButtonElement;
button.disabled = true;
try {
const bestUrl = await bestSpeedBaseUrl();
setPref("translatorSource", bestUrl);
addon.data.prefs?.window.alert(
getString("info-best-speed-source-updated", {
args: { source: bestUrl },
}),
);
} catch (error) {
ztoolkit.log(`select best speed source failed: ${error}`);
addon.data.prefs?.window.alert(
getString("info-best-speed-source-failed"),
);
} finally {
button.disabled = false;
}
});
// metadata source dropdown
// doc
// .querySelector(`#zotero-prefpane-${config.addonRef}-metadata-source-button`)
// ?.addEventListener("click", (e) => {
// e.stopPropagation(); // 阻止事件冒泡
// const pvalues = (getPref("metadataSource") as string).split(", ");
// doc.querySelectorAll("checkbox.metadata-drop-item")!.forEach((e: any) => {
// e.checked = pvalues.includes(e.getAttribute("value")!);
// });
// doc.querySelector("#metadata-source-dropdown")?.classList.toggle("show");
// });
// doc
// .querySelector("#metadata-source-dropdown")
// ?.addEventListener("click", (e) => {
// const checkbox = (e.target as HTMLElement).closest(
// ".metadata-drop-item",
// )!;
// let pvalues = getPref("metadataSource").split(", ") || ["CNKI"];
// if (checkbox.getAttribute("checked") == "true") {
// const checkedSource = checkbox.getAttribute("value")!;
// if (!pvalues.includes(checkedSource)) {
// pvalues.push(checkedSource);
// }
// } else {
// pvalues = pvalues.filter(
// (option) => option !== checkbox.getAttribute("value")!,
// );
// }
// setPref("metadataSource", pvalues.join(", "));
// });
doc
.querySelector(
`#zotero-prefpane-${config.addonRef}-pdf-match-folder-button`,
)
?.addEventListener("click", async (e) => {
const path = await new ztoolkit.FilePicker(
getString("select-download-folder"),
"folder",
[],
).open();
if (path) setPref("pdfMatchFolder", path);
});
// doc
// .querySelector(
// `#zotero-prefpane-${config.addonRef}-install-wps-plugin-button`,
// )
// ?.addEventListener("click", async (e) => {
// ztoolkit.getGlobal("window").alert("等待更新");
// });
}
================================================
FILE: src/modules/preferences/translators.ts
================================================
import { isWindowAlive } from "../../utils/window";
import { getLastUpdatedFromFile, getLastUpdatedMap } from "../translators";
import { config } from "../../../package.json";
import { getString } from "../../utils/locale";
async function onWindowLoad(_window: Window) {
addon.data.translators.window = _window;
await updateRowData();
addon.data.translators.rows = addon.data.translators.allRows;
const columns = [
{
dataKey: "filename",
label: getString("th-filename"),
fixedWidth: false,
},
{
dataKey: "label",
label: getString("th-label"),
fixedWidth: false,
},
{
dataKey: "localUpdateTime",
label: getString("th-local-update-time"),
fixedWidth: true,
width: 145,
},
{
dataKey: "remoteUpdateTime",
label: getString("th-remote-update-time"),
fixedWidth: true,
width: 145,
},
];
addon.data.translators.helper = new ztoolkit.VirtualizedTable(
addon.data.translators.window,
)
.setContainerId("table-container")
.setProp({
id: "translators-table",
columns,
showHeader: true,
staticColumns: false,
})
.setProp("getRowCount", () => addon.data.translators.rows.length)
.setProp(
"getRowData",
(index: number) => addon.data.translators.rows[index],
)
.setProp("onColumnSort", (columnIndex, ascending) => {
// columnIndex from sort event is always valid, so assert its type
const sortKey = columns[columnIndex].dataKey as keyof TableRow;
addon.data.translators.rows.sort((a, b) => {
return ascending > 0
? a[sortKey].localeCompare(b[sortKey])
: b[sortKey].localeCompare(a[sortKey]);
});
updateTableUI();
})
.render();
updateTableUI();
}
async function updateRowData() {
const map = await getLastUpdatedMap(addon.data.env !== "development");
ztoolkit.log("updateRowData", map);
const rows: TableRow[] = [];
for (const [filename, { label, lastUpdated }] of Object.entries(map)) {
rows.push({
filename,
label,
localUpdateTime: (await getLastUpdatedFromFile(filename)) || "--",
remoteUpdateTime: lastUpdated,
});
}
addon.data.translators.allRows = rows;
}
async function updateTableUI() {
return new Promise((resolve) => {
addon.data.translators.helper?.render(undefined, () => {
resolve();
});
});
}
function bindEvents(doc: Document) {
doc.getElementById("github-link")?.addEventListener("click", (event) => {
Zotero.launchURL("https://github.com/l0o0/translators_CN");
});
const searchBox = doc.getElementById("search-box");
searchBox?.addEventListener("command", async (event) => {
ztoolkit.log("search", event);
const value = (event.target as XULTextBoxElement).value;
if (!value) {
addon.data.translators.rows = addon.data.translators.allRows;
} else {
addon.data.translators.rows = addon.data.translators.allRows.filter(
(row) => {
function ignoreCaseIncludes(str: string, search: string) {
return str.toLowerCase().includes(search.toLowerCase());
}
return (
ignoreCaseIncludes(row.filename, value) ||
ignoreCaseIncludes(row.label, value)
);
},
);
}
await updateTableUI();
ztoolkit.log(`Updated table for search: ${value}`);
});
searchBox?.focus();
doc
.getElementById("request-new-translator")
?.addEventListener("click", (event) => {
Zotero.launchURL(
"https://github.com/l0o0/translators_CN/issues/new?template=T3_new_translator.yaml",
);
});
doc
.getElementById("report-translator-bug")
?.addEventListener("click", (event) => {
Zotero.launchURL(
"https://github.com/l0o0/translators_CN/issues/new?template=T1_bug.yaml",
);
});
}
export async function onShowTable() {
if (isWindowAlive(addon.data.translators.window)) {
addon.data.translators.window!.focus();
await updateRowData();
await updateTableUI();
} else {
const windowArgs = {
_initPromise: Zotero.Promise.defer(),
};
const win = Zotero.getMainWindow().openDialog(
`chrome://${config.addonRef}/content/preferences-translators.xhtml`,
"_blank",
"chrome,centerscreen,resizable",
windowArgs,
);
await windowArgs._initPromise.promise;
addon.data.translators.window = win!;
await updateRowData();
onWindowLoad(addon.data.translators.window);
bindEvents(addon.data.translators.window!.document);
}
}
================================================
FILE: src/modules/progress.ts
================================================
import {
ElementProps,
TagElementProps,
} from "zotero-plugin-toolkit/dist/tools/ui";
import { getString } from "../utils/locale";
export class Progress {
public progressWindow: Window | null;
private statusIcons: Record = {};
constructor() {
this.progressWindow = null;
this.statusIcons = {
waiting: "chrome://jasminum/content/icons/loading-loop.svg",
processing: "chrome://jasminum/content/icons/loading-loop.svg",
multiple_results: "chrome://jasminum/content/icons/loading-loop.svg",
success: "chrome://jasminum/content/icons/check.svg",
fail: "chrome://jasminum/content/icons/cross.svg",
};
}
public async openProgressWindow(): Promise {
ztoolkit.log(`Open progress window.`);
const win = Services.wm.getMostRecentWindow("navigator:browser") as Window;
const htmlUrl = "chrome://jasminum/content/progress.xhtml";
const chromeArgs =
"chrome,centerscreen,width=960,height=400,dialog=yes,resizable=no,status=no";
const windowArgs = { _initPromise: Zotero.Promise.defer() };
if (win) {
this.progressWindow = win.openDialog(htmlUrl, "", chromeArgs, windowArgs);
this.progressWindow!.onbeforeunload = (e) => {
this.progressWindow = null;
addon.taskRunner.tasks = [];
};
// For close button in header bar
this.progressWindow!.onclose = (e) => {
this.progressWindow = null;
addon.taskRunner.tasks = [];
};
let t = 0;
// Wait for window
while (
t < 500 &&
this.progressWindow!.document.readyState !== "complete"
) {
// @ts-ignore -- Delay is not typed.
await ztoolkit.getGlobal("Zotero").Promise.delay(10);
t += 1;
}
await windowArgs._initPromise.promise;
} else {
ztoolkit.log(`Maybe this is an error. No main window found.`);
// return Services.ww.openWindow(null, htmlUrl, "", chromeArgs, io);
}
}
private createSearchResultProps(
task: Task,
searchResults: (ScrapeSearchResult | AttachmentSearchResult)[],
): TagElementProps {
return {
tag: "div",
classList: ["search-results-container"],
id: `search-results-container-${task.id}`,
children: [
{
namespace: "html",
tag: "button",
classList: ["confirm-button"],
properties: {
innerText: "确认",
},
attributes: { "data-task-id": task.id },
},
{
tag: "div",
classList: ["search-results"],
id: `search-results-${task.id}`,
children: searchResults.map((result, index) => ({
tag: "div",
classList: ["search-result"],
children: [
{
tag: "input",
properties: {
type: "radio",
name: `task-${task.id}`,
},
attributes: {
"data-task-id": `${task.id}`,
"data-result-index": `${index}`,
},
},
{
tag: "div",
classList: ["info"],
children: [
{
tag: "span",
classList: ["source"],
properties: { innerText: `来源: ${result.source}` },
},
{
tag: "span",
classList: ["title"],
properties: { innerText: `${result.title}` },
},
],
},
],
})),
},
],
};
}
// Add new task to progress window.
public async addTaskToProgressWindow(task: Task): Promise {
if (task.silent === true) return;
if (this.progressWindow == null) {
await this.openProgressWindow();
}
ztoolkit.log("Add task to progress window.");
const taskNodeProps: ElementProps = {
classList: ["task"],
children: [
{
tag: "div",
classList: ["task-header"],
id: `task-header-${task.id}`,
},
],
attributes: { "data-task-id": task.id },
};
const searchContainer: TagElementProps = {
tag: "div",
classList: ["search-results-container"],
id: `search-results-container-${task.id}`,
properties: { style: "display: none;" },
children: [
{
namespace: "html",
tag: "button",
classList: ["confirm-button"],
properties: { innerText: "确认" },
attributes: { "data-task-id": task.id },
},
],
};
//
const taskHeaderChildren: TagElementProps[] = [
{
tag: "img",
classList: ["task-status"],
id: `task-status-${task.id}`,
properties: { src: this.statusIcons[task.status] },
},
{
tag: "span",
classList: ["task-title"],
properties: { innerText: task.item.getField("title") },
},
];
// if (task.searchResult && task.searchResult.length > 0) {
// }
taskNodeProps.children![0].children = taskHeaderChildren;
taskNodeProps.children?.push(searchContainer);
const taskNode = ztoolkit.UI.createElement(
this.progressWindow!.document,
"div",
taskNodeProps,
);
this.progressWindow!.document.querySelector("#task-list")?.appendChild(
taskNode,
);
}
// Convert [text](url) and bare URLs in text to clickable elements
private linkifyMessage(doc: Document, message: string): DocumentFragment {
const fragment = doc.createDocumentFragment();
// Match [text](url) first, then bare URLs
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(https?:\/\/[^\s]+)/g;
const lines = message.split("\n");
lines.forEach((line, lineIndex) => {
let lastIndex = 0;
let match: RegExpExecArray | null;
linkRegex.lastIndex = 0;
while ((match = linkRegex.exec(line)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(
doc.createTextNode(line.slice(lastIndex, match.index)),
);
}
const link = doc.createElement("a");
link.setAttribute("href", "#");
if (match[1]) {
// [text](url) format
link.textContent = match[1];
link.setAttribute("data-url", match[2]);
} else {
// Bare URL
link.textContent = match[3];
link.setAttribute("data-url", match[3]);
}
fragment.appendChild(link);
lastIndex = match.index + match[0].length;
}
if (lastIndex < line.length) {
fragment.appendChild(doc.createTextNode(line.slice(lastIndex)));
}
if (lineIndex < lines.length - 1) {
fragment.appendChild(doc.createElement("br"));
}
});
return fragment;
}
// Update task status icon. Display error msgs when task fails.
public updateTaskStatus(task: Task, status: string): void {
if (this.progressWindow) {
ztoolkit.log(`Progress windows update task status: ${task.id} ${status}`);
this.progressWindow.document
.querySelector(`#task-status-${task.id}`)
?.setAttribute("src", this.statusIcons[status]);
// Display a popover with error msg.
if (status == "fail") {
const doc = this.progressWindow.document;
// Create wrapper for hover area
const wrapper = doc.createElement("span");
wrapper.className = "task-msg-wrapper";
// Create notify icon
const icon = ztoolkit.UI.createElement(doc, "img", {
id: `task-msg-${task.id}`,
classList: ["task-msg"],
properties: {
src: "chrome://jasminum/content/icons/notify.svg",
},
});
// Create popover container
const popover = doc.createElement("div");
popover.className = "task-msg-popover";
popover.id = `task-msg-popover-${task.id}`;
if (task.message) {
popover.appendChild(this.linkifyMessage(doc, task.message));
}
// Handle link clicks with Zotero.launchURL
popover.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.tagName === "A") {
e.preventDefault();
e.stopPropagation();
const url = target.getAttribute("data-url");
if (url) {
Zotero.launchURL(url);
}
}
});
// Show popover on hover
wrapper.addEventListener("mouseenter", () => {
// Close other popovers first
doc.querySelectorAll(".task-msg-popover.visible").forEach((p) => {
p.classList.remove("visible");
});
doc.querySelectorAll(".task-msg.active").forEach((i) => {
i.classList.remove("active");
});
popover.classList.add("visible");
icon.classList.add("active");
});
// Close popover on click outside
doc.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (
!target.closest(".task-msg-popover") &&
!target.closest(".task-msg-wrapper")
) {
popover.classList.remove("visible");
icon.classList.remove("active");
}
});
wrapper.appendChild(icon);
wrapper.appendChild(popover);
doc
.querySelector(`#task-header-${task.id} > span.task-title`)
?.appendChild(wrapper);
}
}
}
public updateTaskSearchResult(
task: Task,
searchResults: (ScrapeSearchResult | AttachmentSearchResult)[],
): void {
if (this.progressWindow) {
ztoolkit.log(searchResults);
const props = this.createSearchResultProps(task, searchResults);
const taskSearchNode = ztoolkit.UI.createElement(
this.progressWindow.document,
"div",
props,
);
const toggle = ztoolkit.UI.createElement(
this.progressWindow.document,
"span",
{
classList: ["toggle-icon"],
id: `toggle-icon-${task.id}`,
properties: { innerText: "▼" },
},
);
// Replace the old search result node with the new one.
this.progressWindow.document
.querySelector(`#search-results-container-${task.id}`)!
.replaceWith(taskSearchNode);
this.progressWindow.document
.querySelector(`#task-header-${task.id}`)!
.appendChild(toggle);
}
}
}
================================================
FILE: src/modules/services/cnki.ts
================================================
import { requestDocument } from "../../utils/http";
import { DocTools, jsonToFormUrlEncoded, text2HTMLDoc } from "../../utils/http";
import { getPref } from "../../utils/prefs";
import { ScraperTask } from "../../utils/task";
/**
* Create post data for CNKI search.
* @param searchOption
* @returns
*/
function createSearchPostOptions(searchOption: SearchOption) {
let url;
const headers = {
Host: "kns.cnki.net",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
Accept: "*/*",
"Accept-Language": "zh-CN,en-US;q=0.9,en;q=0.8",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
Origin: "https://kns.cnki.net",
Referer: `https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=`,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
}; // SU may find more results than TI. SU %= | TI %=
let searchExp: string;
if (searchOption.title.includes(" ")) {
// 过滤掉短的主题词,可以避免出现大量无关结果
const titleParts = searchOption.title
.split(" ")
.filter((i) => i.length > 4);
searchExp =
"(TI %= " +
titleParts.map((_i) => `'${_i}'`).join(" % ") +
" OR SU %= " +
titleParts.join("+") +
")";
} else {
searchExp = `TI %= '${searchOption.title}'`;
}
if (searchOption.author)
searchExp = searchExp + ` AND AU='${searchOption.author}'`;
ztoolkit.log("Search expression: ", searchExp);
const searchExpAside = searchExp.replace(/'/g, "'");
let queryJson;
if (getPref("isMainlandChina")) {
ztoolkit.log("CNKI in mainland China.");
url = "https://kns.cnki.net/kns8s/brief/grid";
queryJson = {
boolSearch: "true",
QueryJson: {
Platform: "",
Resource: "CROSSDB",
Classid: "WD0FTY92",
Products: "",
QNode: {
QGroup: [
{
Key: "Subject",
Title: "",
Logic: 0,
Items: [
{
Key: "Expert",
Title: "",
Logic: 0,
Field: "EXPERT",
Operator: 0,
Value: searchExp,
Value2: "",
},
],
ChildItems: [],
},
{
Key: "ControlGroup",
Title: "",
Logic: 0,
Items: [],
ChildItems: [],
},
],
},
ExScope: "1",
SearchType: 4,
Rlang: "CHINESE",
KuaKuCode:
"YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV",
SearchFrom: 1,
},
pageNum: "1",
pageSize: "20",
sortField: "",
sortType: "",
dstyle: "listmode",
productStr:
"YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,",
aside: `(${searchExpAside})`,
searchFrom: "资源范围:总库;++中英文扩展;++时间范围:更新时间:不限;++",
CurPage: "1",
};
} else {
ztoolkit.log("Using CNKI oversea.");
url = "https://chn.oversea.cnki.net/kns/Brief/GetGridTableHtml";
headers.Host = "www.cnki.net";
headers.Referer = "https://www.cnki.net/kns/defaultresult/index";
headers.Origin = "https://www.cnki.net";
headers.Accept = "text/html, */*; q=0.01";
headers["Accept-Language"] = "zh-CN,zh;q=0.9";
queryJson = {
IsSearch: "true",
QueryJson: {
Platform: "",
DBCode: "CFLS",
KuaKuCode:
"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN",
QNode: {
QGroup: [
{
Key: "Subject",
Title: "",
Logic: 4,
Items: [
{
Key: "Expert",
Title: "",
Logic: 0,
Name: "",
Operate: "",
Value: searchExp,
ExtendType: 12,
ExtendValue: "中英文对照",
Value2: "",
BlurType: "",
},
],
ChildItems: [],
},
{
Key: "ControlGroup",
Title: "",
Logic: 1,
Items: [],
ChildItems: [],
},
],
},
ExScope: 1,
CodeLang: "",
},
PageName: "AdvSearch",
DBCode: "CFLS",
KuaKuCodes:
"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN",
CurPage: "1",
RecordsCntPerPage: "20",
CurDisplayMode: "listmode",
CurrSortField: "",
CurrSortFieldType: "desc",
IsSentenceSearch: "false",
Subject: "",
};
}
// ztoolkit.log(queryJson);
// ztoolkit.log(jsonToFormUrlEncoded(queryJson));
return {
url: url,
data: jsonToFormUrlEncoded(queryJson),
headers: headers,
};
}
async function getRefworksText(
searchResult: ScrapeSearchResult,
): Promise {
const headers = {
Accept: "text/plain, */*; q=0.01",
"Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3",
"Content-Type": "application/x-www-form-urlencoded",
Host: "kns.cnki.net",
Origin: "https://www.cnki.net",
Priority: "u=0",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
Referer: searchResult.url,
};
const isMainlandChina = getPref("isMainlandChina");
if (getPref("isMainlandChina")) {
// "1": row's sequence in search result page, defualt 1; "0": index of page in search result pages, defualt 0.
const platform = "NZKPT";
const apiUrl = "https://kns.cnki.net/dm8/API/GetExport";
let responseText: string;
let postData = isMainlandChina
? `filename=${searchResult.exportID}&uniplatform=${platform}`
: `filename=${searchResult.dbname}!${searchResult.filename}!1!0`;
postData += "&displaymode=GBTREFER%2Celearning%2CEndNote";
const resp = await Zotero.HTTP.request("POST", apiUrl, {
body: postData,
headers: headers,
cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),
timeout: 10000,
successCodes: [200, 403],
});
ztoolkit.log(`Endnote reference text from CNKI: ${resp.responseText}`);
responseText = resp.responseText;
if (resp.status === 403) {
ztoolkit.log(
"CNKI access forbidden (403). This is likely due to missing or invalid cookies.",
);
const respJson = JSON.parse(resp.responseText);
ztoolkit.log("Retrying CNKI search after updating cookies...");
headers["Referer"] = respJson.message;
const resp2 = await Zotero.HTTP.request("POST", apiUrl, {
headers: headers,
body: postData,
cookieSandbox: await addon.data.myCookieSandbox.passCaptchaToCookieBox(
respJson.message,
"CNKI:Home",
),
timeout: 10000,
successCodes: [200, 403],
});
responseText = resp2.responseText;
}
const returnJson = JSON.parse(responseText);
if (returnJson.code != 1) {
return null;
} else {
const endnoteRef = returnJson.data.find(
(i: Record) => i.key === "EndNote",
);
if (endnoteRef) {
return endnoteRef.value[0].replace(/ /g, "\n");
} else {
return null;
}
}
} else {
ztoolkit.log("CNKI oversea export reference.");
const apiUrl = "https://chn.oversea.cnki.net/kns/Manage/APIGetExport";
// TODO: implement oversea export
return null;
}
}
async function getSnapshotItem(
item: Zotero.Item,
): Promise {
const regx = new RegExp(
"/(kns8?s?|kcms2?)/(article/abstract\\?|detail/detail\\.aspx\\?)",
"i",
);
if (item.itemType == "webpage" && regx.test(item.getField("url"))) {
const attachmentItem = Zotero.Items.get(item.getAttachments()).find(
(attachment) => {
return (
attachment.isSnapshotAttachment() &&
regx.test(attachment.getField("url"))
);
},
);
if (attachmentItem === undefined) return undefined;
const filePath = await attachmentItem.getFilePathAsync();
if (filePath) return attachmentItem;
}
return undefined;
}
// Update addtional information to the item.
// Citations from CNKI, Use keyword: CNKICite
async function updateItem(
item: Zotero.Item | null,
searchResult: ScrapeSearchResult,
): Promise {
if (item) {
if (searchResult.citation) {
ztoolkit.ExtraField.setExtraField(
item,
"CNKICite",
`${searchResult.citation}`,
);
}
if (searchResult.netFirst) {
ztoolkit.ExtraField.setExtraField(
item,
"Status",
"advance online publication",
);
}
// Remove unmatched Zotero fields note.
if (item.getNotes().length > 0) {
item.getNotes().forEach(async (nid) => {
const nItem = Zotero.Items.get(nid);
await nItem.eraseTx();
});
}
if (!item.getField("date") && searchResult.date) {
item.setField("date", searchResult.date);
}
}
return item;
}
export class CNKI implements ScrapeService {
async search(
searchOption: SearchOption,
): Promise {
ztoolkit.log("serch options: ", searchOption);
const postOption = createSearchPostOptions(searchOption);
let responseText: string;
const resp = await Zotero.HTTP.request("POST", postOption.url, {
headers: postOption.headers,
body: postOption.data,
cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),
timeout: 10000,
successCodes: [200, 403],
});
ztoolkit.log("CNKI search response: ", resp);
responseText = resp.responseText;
if (resp.status === 403) {
ztoolkit.log(
"CNKI search access forbidden (403). This is likely due to missing or invalid cookies.",
);
const respJson = JSON.parse(resp.responseText);
ztoolkit.log("Retrying CNKI search after updating cookies...");
await addon.data.myCookieSandbox.passCaptchaToCookieBox(
respJson.message,
"CNKI:Home",
);
postOption.headers["Referer"] = respJson.message;
ztoolkit.log("Refer", postOption.headers);
const resp2 = await Zotero.HTTP.request("POST", postOption.url, {
headers: postOption.headers,
body: postOption.data,
cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),
timeout: 10000,
successCodes: [200, 403],
});
ztoolkit.log("CNKI retry search response: ", resp2);
responseText = resp2.responseText;
}
ztoolkit.log("CNKI final search response: ", responseText);
const searchDoc = text2HTMLDoc(responseText);
const resultRows = searchDoc.querySelectorAll(
"table.result-table-list > tbody > tr",
);
ztoolkit.log(`CNKI search result: ${resultRows.length}`);
if (resultRows.length == 0) {
ztoolkit.log("CNKI no items found.");
return null;
} else {
const resultData = Array.from(resultRows).map((r) => {
const dt = new DocTools(r as HTMLElement);
let url = dt.attr("a.fz14", "href")!;
// Missing host in CNKI oversea.
if (!url.startsWith("http")) {
url = "https://chn.oversea.cnki.net" + url;
}
const title = ` ${dt.innerText("td.seq")} ${dt.innerText("td.data")} ${dt.innerText("td.name a")} ${dt.innerText("td.author").replace(" ", ",")} ${dt.innerText("td.source")} ${dt.innerText("td.date")}`;
return {
source: "CNKI",
title: title,
articleTitle: dt.innerText("td.name a"),
url: url,
date: Zotero.Date.strToISO(dt.innerText("td.date")) || "",
netFirst: dt.innerText("td.name > b.marktip"),
citation: dt.innerText("td.quote"),
exportID: dt.attr("td.seq input", "value"),
dbname: dt.attr("td.operat > [data-dbname]", "data-dbname"),
filename: dt.attr("td.operat > [data-dbname]", "data-filename"),
};
});
return resultData;
}
}
async translate(
searchResult: ScrapeSearchResult,
libraryID: number,
saveAttachments: false,
): Promise {
let translatedItems: Zotero.Item[] = [];
let isWebTranslated = true;
try {
const doc = await requestDocument(searchResult.url, {
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Referer: "https://kns.cnki.net/kns8s/AdvSearch",
"Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
},
cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),
});
ztoolkit.log(`Document title: ${doc.title}`);
if (doc.title != "知网节超时验证" && doc.title != "captcha") {
// @ts-ignore - Translate is not typed.
const translator = new Zotero.Translate.Web();
// CNKI.js
// If the loading of translators fails, the following code might return nothing.
translator.setTranslator("5c95b67b-41c5-4f55-b71a-48d5d7183063");
translator.setDocument(doc);
translatedItems = await translator.translate({
libraryID: libraryID,
saveAttachments: saveAttachments,
});
} else {
isWebTranslated = false;
}
} catch (e) {
ztoolkit.log(`CNKI web translation failed: ${e}`);
addon.taskRunner.runningTask?.addMsg(`CNKI web translation failed: ${e}`);
isWebTranslated = false;
}
// Another translation for CNKI.
if (isWebTranslated == false) {
try {
ztoolkit.log("知网网页出现验证码或其他异常,准备获取其他格式文献信息");
const refworksText = await getRefworksText(searchResult);
if (!refworksText) {
ztoolkit.log("CNKI reference text is null.");
addon.taskRunner.runningTask?.addMsg("CNKI reference text is null.");
return [];
}
ztoolkit.log("Formated Refworks text: ", refworksText);
const translate = new Zotero.Translate.Import();
translate.setTranslator("7b6b135a-ed39-4d90-8e38-65516671c5bc");
translate.setString(refworksText);
translatedItems = await translate.translate({
libraryID: libraryID,
saveAttachments: false,
});
} catch (e) {
ztoolkit.log(`CNKI refwork translation failed: ${e}`);
throw `CNKI refwork translation failed: ${e}`;
}
}
return translatedItems;
}
// CNKI webpage item or snapshot item.
async searchSnapshot(
task: ScraperTask,
): Promise {
ztoolkit.log("Start to search for snapshot");
let webpageItem: Zotero.Item;
let attachmentItem: Zotero.Item | undefined;
let searchResults: ScrapeSearchResult[] | null = null;
if (task.item.isTopLevelItem()) {
webpageItem = task.item;
attachmentItem = await getSnapshotItem(task.item);
} else {
// Snapshot item must have an valid parent item?
webpageItem = task.item.parentItem!;
attachmentItem = task.item;
}
// Find snapshot attachment,
if (attachmentItem) {
const filePath = (await attachmentItem.getFilePathAsync()) as string;
// Maybe we can find some usefull data from the snapshot page.
const doc = text2HTMLDoc(
(await Zotero.File.getContentsAsync(filePath)) as string,
attachmentItem.getField("url"),
);
const dt = new DocTools(doc);
// http://x.cnki.net/search/common/testlunbo?dbcode=CJFQ&tablename=CJFDAUTO&filename=ZWBH202405039&filesourcetype=1
const noteUrl = dt.attr("li[title='记笔记'].btn-note > a", "href");
// https://aiplus.cnki.net/aiplus/direct?cid=Pe2nFq1PBOM11SpCErZ-LwM1UHjV0uMR_icN4IXwgidjURR2ddM6CTa9OS-R4yps7kfD7g5Wa4sKEufH3KeS74nDa1x0Roidi_RcpyaNH-4!&mimetype=XML
const aiUrl = dt.attr("li.btn-cnki-ai > a", "href");
const noteParams = new URLSearchParams(noteUrl.split("?")[1]);
const aiParams = new URLSearchParams(aiUrl.split("?")[1]);
searchResults = [
{
source: "CNKI",
title: attachmentItem.getField("title"),
url: attachmentItem.getField("url"),
dbcode: noteParams.get("dbcode"),
dbname: noteParams.get("tablename"),
filename: noteParams.get("filename"),
exportID: aiParams.get("cid"),
},
];
ztoolkit.log("Found searchResult in snapshot page", searchResults[0]);
}
// Found nothing in the snapshot page. Use CNKI search.
if (searchResults === null) {
const searchOption: SearchOption = {
title: webpageItem.getField("title").replace(/ - 中国知网$/g, ""),
};
searchResults = await this.search(searchOption);
ztoolkit.log("Found searchResult from CNKI search", searchResults);
}
return searchResults || null;
}
}
================================================
FILE: src/modules/services/index.ts
================================================
import { getArgsFromPattern } from "../../utils/pattern";
import { getPDFTitle } from "../../utils/pdfParser";
import { getPref } from "../../utils/prefs";
import { ScraperTask } from "../../utils/task";
import { isChineseTopAttachment, isChinsesSnapshot } from "../../utils/detect";
import { CNKI } from "./cnki";
// import { PubScholar } from "./pubscholar";
import { Yiigle } from "./yiigle";
import { compareTwoStrings } from "string-similarity";
const cnki = new CNKI();
// const pubscholar = new PubScholar();
const yiigle = new Yiigle();
async function getSearchOption(
item: Zotero.Item,
): Promise {
let namepattern = getPref("namePattern");
// Get title from pdf page content.
// 1: title from PDF, 2: {%t}_{%g}
if (namepattern == "auto") {
let title = undefined;
try {
title = await getPDFTitle(item.id);
} catch (e) {
ztoolkit.log(`Pdf parsing error ${e}`);
}
if (title) return { title };
return getArgsFromPattern(item.attachmentFilename, "{%t}_{%g}");
} else {
if (namepattern == "custom") namepattern = getPref("namePatternCustom");
return getArgsFromPattern(item.attachmentFilename, namepattern);
}
}
export async function metaSearch(
task: ScraperTask,
options?: any,
): Promise {
// const scrapeServices = getPref("metadataSource").split(", ") || ["CNKI"];
if (!isChineseTopAttachment(task.item) && !isChinsesSnapshot(task.item)) {
ztoolkit.log("No Chinese attachment or snapshot items found. Stop search.");
return;
}
ztoolkit.log("search task", task);
task.status = "processing";
// Searching by different scrape services
let scrapeSearchResults: ScrapeSearchResult[] = [];
if (task.type == "attachment") {
const searchOption = await getSearchOption(task.item);
task.addMsg(
`Region: ${getPref("isMainlandChina") ? "Mainland China" : "Overseas"}`,
);
task.addMsg(`Search pattern: ${getPref("namePattern")}`);
task.addMsg(`Search option: ${JSON.stringify(searchOption)}`);
if (searchOption) {
const cnkiSearchResult = await cnki.search(searchOption);
ztoolkit.log("cnki results", cnkiSearchResult);
if (cnkiSearchResult) {
task.addMsg(`Found ${cnkiSearchResult.length} results from CNKI`);
scrapeSearchResults = scrapeSearchResults.concat(cnkiSearchResult);
}
// const pubscholarSearchResult = await pubscholar.search(searchOption);
// ztoolkit.log("pubscholar results", pubscholarSearchResult);
// if (pubscholarSearchResult) {
// task.addMsg(
// `Found ${pubscholarSearchResult.length} results from PubScholar`,
// );
// scrapeSearchResults = scrapeSearchResults.concat(
// pubscholarSearchResult,
// );
// }
const yiigleSearchResult = await yiigle.search(searchOption);
ztoolkit.log("yiigle results", yiigleSearchResult);
if (yiigleSearchResult) {
task.addMsg(`Found ${yiigleSearchResult.length} results from Yiigle`);
scrapeSearchResults = scrapeSearchResults.concat(yiigleSearchResult);
}
// Filter search results
const filteredResults1 = scrapeSearchResults.filter((result) => {
return (result.articleTitle as string).includes(searchOption.title);
});
const filteredResults2 = scrapeSearchResults.filter((result) => {
const score = compareTwoStrings(
searchOption.title,
result.articleTitle as string,
);
ztoolkit.log(`Similarity score for "${result.articleTitle}": ${score}`);
return (
!(result.articleTitle as string).includes(searchOption.title) &&
score > parseFloat(getPref("similarityThresholdForMetaData"))
);
});
scrapeSearchResults = filteredResults1.concat(filteredResults2);
task.addMsg(
`After filtering, ${scrapeSearchResults.length} results left.`,
);
} else {
task.addMsg("Filename parsing error");
task.status = "fail";
}
} else if (task.type == "snapshot") {
const tmp = await cnki.searchSnapshot!(task);
if (tmp) scrapeSearchResults = scrapeSearchResults.concat(tmp);
}
ztoolkit.log("all results: ", scrapeSearchResults);
if (scrapeSearchResults.length == 0) {
task.addMsg("No search results");
task.status = "fail";
} else if (scrapeSearchResults.length > 1) {
task.status = "multiple_results";
}
task.searchResults = scrapeSearchResults;
}
export async function metaTranslate(task: ScraperTask): Promise {
if (task.searchResults.length === 0) {
task.addMsg("No search results found.");
task.status = "fail";
}
try {
const resultIndex = task.resultIndex || 0; // default is 0
task.resultIndex = resultIndex;
const searchResult = task.searchResults[resultIndex];
const libraryID = task.item.libraryID;
ztoolkit.log(`start translate for search result: ${searchResult.title}`);
let translatedItems: Zotero.Item[] = [];
try {
switch (searchResult.source) {
case "CNKI":
ztoolkit.log("translated by CNKI");
translatedItems = await cnki.translate(
searchResult,
libraryID,
false,
);
break;
// case "PubScholar":
// ztoolkit.log("translated by PubScholar");
// newItem = await pubscholar.translate(task, false);
// break;
case "中华医学":
ztoolkit.log("translated by Yiigle");
translatedItems = await yiigle.translate(
searchResult,
libraryID,
false,
);
break;
default:
break;
}
ztoolkit.log(translatedItems);
} catch (e) {
ztoolkit.log(`Translation error: ${e}`);
task.addMsg(`Translation error: ${e}`);
}
if (translatedItems.length === 1) {
// if (addon.data.env != "development")
const translatedItem = await globalItemFix(task.item, translatedItems[0]);
if (task.type == "attachment") {
task.item.parentID = translatedItem.id;
} else if (task.type == "snapshot") {
if (task.item.isTopLevelItem()) {
ztoolkit.log("Translate snapshot item for webpage item");
const tmpJSON = translatedItem.toJSON();
task.item.fromJSON(tmpJSON);
await translatedItem.eraseTx();
} else {
ztoolkit.log("Translate snapshot attachment item");
const oldParentItem = task.item.parentItem!;
const collectionIDs = oldParentItem.getCollections();
task.item.parentID = translatedItem.id;
// When parent item is erased, the attachment item will be erased. Set new parent item before the old parent will be earsed.
await task.item.saveTx();
await oldParentItem.eraseTx();
translatedItem.setCollections(collectionIDs);
await translatedItem.saveTx();
}
}
await task.item.saveTx();
task.status = "success";
} else if (translatedItems.length > 1) {
task.addMsg(
`Multiple items (${translatedItems.length}) translated, please check details.`,
);
task.status = "fail";
} else {
task.addMsg("Translation error");
task.status = "fail";
}
} catch (e) {
task.addMsg(`ERROR: ${e}`);
task.status = "fail";
}
}
// Need to update data in item returned by translator.
async function globalItemFix(
oldItem: Zotero.Item,
newItem: Zotero.Item,
): Promise {
if (Zotero.Prefs.get("extensions.zotero.automaticTags", true)) {
// Keyword tag type is automatic.
ztoolkit.log("update auto tags");
newItem.setTags(
newItem.getTags().map((t: { tag: string; type?: number }) => ({
tag: t.tag,
type: 1,
})),
);
} else {
// Remove automatic tags
ztoolkit.log("remove all tags");
newItem.removeAllTags();
}
// Preserve collections
oldItem.getCollections().forEach((cid) => newItem!.addToCollection(cid));
await newItem.saveTx();
return newItem;
}
================================================
FILE: src/modules/services/pubscholar.ts
================================================
import { requestDocument } from "../../utils/http";
import { DocTools, text2HTMLDoc } from "../../utils/http";
import { ScraperTask } from "../../utils/task";
const BASE_URL = "https://pubscholar.cn";
/**
* Parse search results from PubScholar response.
*/
function parseSearchResults(doc: Document): ScrapeSearchResult[] {
// TODO: Update selector based on actual PubScholar page structure
const resultRows = doc.querySelectorAll(".result-item");
if (resultRows.length === 0) {
ztoolkit.log("PubScholar: no items found.");
return [];
}
return Array.from(resultRows).map((r) => {
const dt = new DocTools(r as HTMLElement);
// TODO: Update selectors to match PubScholar's HTML structure
const title = dt.innerText(".result-title") || "";
const url = dt.attr(".result-title a", "href") || "";
const author = dt.innerText(".result-author") || "";
const source = dt.innerText(".result-source") || "";
const date = dt.innerText(".result-date") || "";
return {
source: "PubScholar",
title: `${title} ${author} ${source} ${date}`,
url: url.startsWith("http") ? url : `${BASE_URL}${url}`,
date: Zotero.Date.strToISO(date) || "",
};
});
}
/**
* Build a Zotero item from PubScholar detail page metadata.
*/
async function createItemFromMetadata(
metadata: Record,
libraryID: number,
): Promise {
// TODO: Map PubScholar metadata fields to Zotero item fields
// Example:
// const item = new Zotero.Item("journalArticle");
// item.libraryID = libraryID;
// item.setField("title", metadata.title);
// item.setField("date", metadata.date);
// ...
// await item.saveTx();
// return item;
return null;
}
export class PubScholar implements ScrapeService {
async search(
searchOption: SearchOption,
): Promise {
ztoolkit.log("PubScholar search options: ", searchOption);
let query = searchOption.title;
if (searchOption.author) {
query += ` ${searchOption.author}`;
}
// TODO: Implement PubScholar search API call
// Step 1: Build search URL and parameters
const searchUrl = `${BASE_URL}/api/search`;
// Step 2: Send HTTP request
// const resp = await Zotero.HTTP.request("POST", searchUrl, {
// headers: {
// "Content-Type": "application/json",
// "User-Agent": "Mozilla/5.0 ...",
// },
// body: JSON.stringify({ q: query, page: 1, pageSize: 20 }),
// timeout: 10000,
// });
// Step 3: Parse response
// const doc = text2HTMLDoc(resp.responseText);
// const results = parseSearchResults(doc);
// return results.length > 0 ? results : null;
return null;
}
async translate(
searchResult: ScrapeSearchResult,
libraryID: number,
saveAttachments: false,
): Promise {
ztoolkit.log(`PubScholar translate: ${searchResult.title}`);
// TODO: Implement PubScholar translation
// Strategy 1: Use Zotero Web Translator if a matching translator exists
// try {
// const doc = await requestDocument(searchResult.url, {
// headers: { ... },
// });
// const translator = new Zotero.Translate.Web();
// translator.setTranslator("TRANSLATOR_ID");
// translator.setDocument(doc);
// const items = await translator.translate({
// libraryID: task.item.libraryID,
// saveAttachments: saveAttachments,
// });
// if (items.length === 1) return items[0];
// } catch (e) {
// ztoolkit.log(`PubScholar web translation failed: ${e}`);
// }
// Strategy 2: Fetch metadata from detail page and build item manually
// const metadata = await this.fetchDetailMetadata(searchResult.url);
// return createItemFromMetadata(metadata, task.item.libraryID);
return [];
}
}
================================================
FILE: src/modules/services/yiigle.ts
================================================
import { compareTwoStrings } from "string-similarity";
import { DocTools, requestDocument } from "../../utils/http";
const { HiddenBrowser } = ChromeUtils.importESModule(
"chrome://zotero/content/HiddenBrowser.mjs",
);
export class Yiigle implements ScrapeService {
async search(
searchOption: SearchOption,
): Promise {
ztoolkit.log("Yiigle search started.");
const url = `https://www.yiigle.com/Paper/Search?type=&q=${encodeURIComponent(searchOption.title)}&searchType=pt`;
ztoolkit.log("Yiigle search URL: " + url);
// @ts-ignore not typed
const browser = new HiddenBrowser();
const extractArticleData = (node: HTMLElement): ScrapeSearchResult => {
const dt = new DocTools(node);
const title = dt.attr("a[title].el-link--default", "title");
const url = dt.attr('a[href*="rs.yiigle.com/cmaid/"]', "href");
// 3. 提取引用量(兼容PC/移动端DOM结构)
const citation = parseInt(dt.innerText("span > samp", 2)) || 0;
// 4. 提取articleID(从URL末尾截取数字)
const articleIDMatch = url.match(/\/(\d+)$/); // 匹配 /xxx 最后一段的数字
const articleID = articleIDMatch ? articleIDMatch[1] : "";
// 期刊类型
const jtype = dt.innerText(
"div.s_searchResult_li_top.el-row.el-row--flex > span.w_span.hidden-sm-and-down:not([style*='display: none'])",
0,
);
// 作者等信息
const infoText = dt
.innerText("div.s_searchResult_li_author.el-row", 0)
.replaceAll("\n", "");
// 返回标准化对象
const result: ScrapeSearchResult = {
source: "中华医学",
title: ` ${jtype} ${title} ${infoText}`,
url: url,
articleID: articleID,
articleTitle: title,
};
if (citation > 0) {
result.citation = citation;
}
return result;
};
try {
await browser.load(url);
await browser.waitForDocument({ allowInteractiveAfter: 5000 });
setTimeout(() => {
ztoolkit.log("1秒延迟到了!");
}, 1000);
const doc = await browser.getDocument();
ztoolkit.log(`Yiigle search document title: ${doc.title}`);
const items = doc.querySelectorAll("div.s_searchResult_li.el-row");
ztoolkit.log(`Yiigle search: found ${items.length} items.`, items);
if (items.length === 0) {
ztoolkit.log("Yiigle search: no results found.");
return null;
} else {
return Array.from(items).map((item) =>
extractArticleData(item as HTMLElement),
);
}
} catch (error) {
ztoolkit.log("Yiigle search error: " + error);
} finally {
browser.destroy();
}
return null;
}
async translate(
searchResult: ScrapeSearchResult,
libraryID: number,
saveAttachments: false,
): Promise {
ztoolkit.log("Yiigle translate started.");
const doc = await requestDocument(searchResult.url, {
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,en-US;q=0.9,en;q=0.8",
Referer: "https://www.yiigle.com/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
},
});
ztoolkit.log(`Document title: ${doc.title}`);
const translator = new Zotero.Translate.Web();
translator.setTranslator("f5189d31-18ea-4e84-bdec-f1d0e75b818b");
translator.setDocument(doc);
const translatedItems = await translator.translate({
libraryID: libraryID,
saveAttachments: saveAttachments,
});
return translatedItems;
}
}
================================================
FILE: src/modules/styles.ts
================================================
import { get } from "http";
import { getString } from "../utils/locale";
import { findWindow, observeWindowLoad, waitElmLoaded } from "../utils/window";
import { setDashPattern } from "pdf-lib";
function injectToDocument(doc: Document) {
const labelId = "zotero-chinese-styles-link";
// Already injected
if (doc.getElementById(labelId)) {
ztoolkit.log("Chinese styles link already injected");
return;
}
function injectToParent() {
// ztoolkit.log("Injecting Chinese styles link to preferences");
waitElmLoaded(doc, "#styleManager-buttons", 8000).then(() => {
// ztoolkit.log("Preferences loaded, injecting link");
const firstChild = doc.querySelector(
"#styleManager-buttons > :nth-child(1)",
);
const secondChild = doc.querySelector(
"#styleManager-buttons > :nth-child(2)",
);
// ztoolkit.log(firstChild?.tagName);
if (!firstChild || !secondChild) return;
if (firstChild.tagName === "button") {
const hbox_copy = secondChild
.querySelector("hbox")!
.cloneNode(true) as HTMLElement;
hbox_copy
.querySelector("label")!
.setAttribute("value", getString("get-Chinese-styles"));
const button = doc.createElement("button");
button.style.padding = "0px";
button.id = labelId;
button.setAttribute("label", getString("get-Chinese-styles"));
button.addEventListener("click", function (event) {
Zotero.launchURL("https://zotero-chinese.com/styles/");
event.preventDefault();
});
button.appendChild(hbox_copy);
secondChild.insertAdjacentElement("beforebegin", button);
} else if (firstChild.tagName === "label") {
// For Zotero 7
const label = doc.createElement("label");
label.id = labelId;
label.classList.add("zotero-text-link");
label.setAttribute("is", "zotero-text-link");
label.setAttribute("role", "link");
label.textContent = getString("get-Chinese-styles");
label.addEventListener("click", function (event) {
Zotero.launchURL("https://zotero-chinese.com/styles/");
event.preventDefault();
});
firstChild.removeAttribute("flex");
firstChild.style.marginRight = "12px";
firstChild.insertAdjacentElement("afterend", label);
}
ztoolkit.log("Chinese styles link injected");
});
}
const isCitePaneSelected = doc.querySelector(
"richlistitem[value='zotero-prefpane-cite'][selected='true']",
);
// If cite pane is selected, insert immediately
if (isCitePaneSelected) {
injectToParent();
} else {
const navigation = doc.getElementById("prefs-navigation");
if (!navigation) return;
function onSelect(event: Event) {
// Inject link only one time in window lifetime
navigation!.removeEventListener("select", onSelect);
injectToParent();
}
navigation.addEventListener("select", onSelect);
}
}
/**
* Inject a link to the Chinese styles page into the preferences window.
*/
export async function injectStylesLink() {
const prefsUri = "chrome://zotero/content/preferences/preferences.xhtml";
const existingWindow = findWindow(prefsUri);
if (existingWindow) {
injectToDocument(existingWindow.document);
}
// Wait for preference window loaded next time
observeWindowLoad(prefsUri, (win) => injectToDocument(win.document));
}
================================================
FILE: src/modules/tools.ts
================================================
import { config } from "../../package.json";
import { isChineseTopItem } from "./../utils/detect";
import { getString } from "../utils/locale";
import { getPref } from "../utils/prefs";
import { CNKI } from "../modules/services/cnki";
import { findAttachmentsInFolder } from "./attachments/localMatch";
import { actionAfterImport } from "./attachments";
// 中国稀有姓氏统计小组发布于小红书ID4975028282
// https://www.xiaohongshu.com/discovery/item/67c017cb000000001203db3d
const compoundSurnames = [
/* A */
"奥屯",
/* B */
"百里",
"比干",
"单于",
/* C */
"陈留",
"成公",
"成功",
"叱干",
"褚师",
"淳于",
/* D */
"达奚",
"第二",
"第五",
"第伍",
"第一",
"丁若",
"东方",
"东里",
"东门",
"东野",
"豆卢",
"独孤",
"端木",
"段干",
/* E */
"尔朱",
/* F */
"伏羲",
"状阳",
"傅阳",
/* G */
"高堂",
"高阳",
"哥舒",
"葛天",
"公乘",
"公上",
"公孙",
"公羊",
"公冶",
"共工",
"古野",
"关龙",
"毌丘",
/* H */
"韩城",
"贺兰",
"贺楼",
"贺若",
"赫连",
"呼延",
"胡母",
"胡毋",
"斛律",
"华原",
"皇甫",
"皇父",
/* K */
"可汗",
/* J */
"即墨",
"夹谷",
"揭阳",
/* L */
"令狐",
"闾丘",
"闾邱",
/* M */
"马服",
"万矣",
"墨台",
"默台",
"母丘",
"木易",
"慕容",
/* N */
"南宫",
"南门",
"女娲",
/* O */
"欧侯",
"欧阳",
/* P */
"濮阳",
"蒲察",
/* Q */
"漆雕",
"亓官",
"綦连",
"綦毋",
"气伏",
"青阳",
"屈男",
"屈突",
/* S */
"上官",
"申徒",
"申屠",
"石抹",
"士孙",
"侍其",
"水丘",
"司城",
"司空",
"司寇",
"司马",
"司徒",
"司星",
"澹台",
/* T */
"拓跋",
"太史",
"太叔",
"徒单",
"涂山",
"脱脱",
/* W */
"完颜",
"闻人",
"武城",
"毋丘",
/* X */
"西门",
"夏侯",
"夏后",
"鲜于",
"相里",
"轩辕",
/* Y */
"延陵",
"羊舌",
"耶律",
"宇文",
"尉迟",
"乐正",
/* Z */
"宰父",
"长孙",
"钟离",
"诸葛",
"术虎",
"主父",
"祝融",
"颛孙",
"颛项",
"子车",
"宗正",
"宗政",
/* 璧联姓 */
"邓李",
"刘付",
"陆费",
"吴刘",
];
export async function splitName(item: Zotero.Item): Promise {
const creators = item.getCreators();
for (const creator of creators) {
if (creator.fieldMode === 0 && creator.firstName !== "") continue;
if (
/\p{Unified_Ideograph}/u.test(`${creator.lastName}${creator.firstName}`)
) {
const fullName = creator.lastName;
const surname = compoundSurnames.find((surname) =>
creator.lastName.startsWith(surname),
);
if (fullName.includes("·")) {
const nameParts = fullName.split("·");
creator.lastName = nameParts.shift()!;
creator.firstName = nameParts.join("·");
} else if (surname) {
creator.lastName = surname;
creator.firstName = fullName.slice(surname.length);
} else {
creator.lastName = fullName.charAt(0);
creator.firstName = fullName.slice(1);
}
creator.fieldMode = 0;
} else if (getPref("splitEnName") && /[a-z]/i.test(creator.lastName)) {
const nameParts = creator.lastName.split(/\s+/g);
if (nameParts.length > 1) {
creator.lastName = nameParts.pop()!;
creator.firstName = nameParts.join(" ");
creator.fieldMode = 0;
}
}
}
item.setCreators(creators);
await item.saveTx();
}
export async function mergeName(item: Zotero.Item): Promise {
const creators = item.getCreators();
for (const creator of creators) {
if (
/\p{Unified_Ideograph}/u.test(`${creator.firstName}${creator.lastName}`)
) {
if (
// Chinese Name in One field.
creator.fieldMode === 1 &&
creator.lastName.length - 2 === creator.lastName.indexOf(" ")
) {
creator.lastName = creator.lastName.split(" ").reverse().join("");
} else {
// 由于拆分后信息丢失,难以判断少数民族的姓氏,这里的条件是充分不必要的
const delimiter = creator.firstName.includes("·") ? "·" : "";
creator.lastName = `${creator.lastName}${delimiter}${creator.firstName}`;
}
creator.firstName = "";
creator.fieldMode = 1;
} else if (getPref("splitEnName") && /[a-z]/i.test(creator.lastName)) {
creator.lastName = `${creator.firstName} ${creator.lastName}`.trimStart();
creator.firstName = "";
creator.fieldMode = 1;
}
}
item.setCreators(creators);
await item.saveTx();
}
export async function getCNKICite(item: Zotero.Item): Promise {
const cnki = new CNKI();
const searchOption = {
title: item.getField("title"),
author: item.getCreators()[0].lastName + item.getCreators()[0].firstName,
};
let cite = "";
const searchResults = await cnki.search(searchOption);
if (searchResults && searchResults.length > 0) {
cite = searchResults[0].citation as string;
ztoolkit.log(`CNKI citation: ${cite}`);
if (cite) {
ztoolkit.ExtraField.setExtraField(item, "CNKICite", cite);
}
}
return cite;
}
export async function updateCNKICite(items: Zotero.Item[]) {
const items2 = items.filter((i) => isChineseTopItem(i));
if (items2.length > 0) {
let popupWin;
for (let i = 0; i < items2.length; i++) {
const cite = await getCNKICite(items2[i]);
if (i == 0) {
popupWin = new ztoolkit.ProgressWindow(config.addonName, {
closeOnClick: true,
closeTime: 1500,
})
.createLine({
text: `${getString("citation")}:${cite ? cite : "0"} ${items[i].getField("title")}`,
type: "default",
icon: `chrome://${config.addonRef}/content/icons/cite.png`,
})
.show();
} else {
popupWin?.changeLine({
text: `${getString("citation")}:${cite ? cite : "0"} ${items[i].getField("title")}`,
type: "default",
icon: `chrome://${config.addonRef}/content/icons/cite.png`,
});
}
}
} else {
ztoolkit.log("No Chinese items to update citation.");
new ztoolkit.ProgressWindow(config.addonName, {
closeOnClick: true,
closeTime: 3500,
})
.createLine({
text: getString("no-chinese-item-for-citation"),
type: "default",
icon: `chrome://${config.addonRef}/content/icons/cite.png`,
})
.show();
}
}
async function renameAttachmentFromParent(attachmentItem: Zotero.Item) {
if (
!attachmentItem.isAttachment() ||
attachmentItem.isTopLevelItem() ||
attachmentItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL
) {
throw `Item ${attachmentItem.id} is not a child file attachment in ZoteroPane_Local.renameAttachmentFromParent()`;
}
const filePath = await attachmentItem.getFilePathAsync();
if (!filePath) return;
const parentItemID = attachmentItem.parentItemID as number;
const parentItem = await Zotero.Items.getAsync(parentItemID);
let newName = Zotero.Attachments.getFileBaseNameFromItem(parentItem);
const extRE = /\.[^.]+$/;
const origFilename = PathUtils.filename(filePath);
const ext = origFilename.match(extRE);
if (ext) {
newName = newName + ext[0];
}
const origFilenameNoExt = origFilename.replace(extRE, "");
const renamed = await attachmentItem.renameAttachmentFile(
newName,
false,
true,
);
if (renamed !== true) {
ztoolkit.log(`Could not rename file (${renamed})`);
}
// If the attachment title matched the filename, change it now
const origTitle = attachmentItem.getField("title");
if ([origFilename, origFilenameNoExt].includes(origTitle)) {
attachmentItem.setField("title", newName);
await attachmentItem.saveTx();
}
}
export async function importAttachmentsFromFolder(): Promise {
let msgType = "default";
let msg: string = "";
if (addon.data.isImportingAttachments) {
Zotero.getMainWindow().alert(getString("importing-attachments-is-running"));
return;
}
try {
const folder = getPref("pdfMatchFolder");
const collectionID =
Zotero.getActiveZoteroPane().getSelectedCollection()!.id;
const attachmentFilenames = await findAttachmentsInFolder(folder);
ztoolkit.log(collectionID, attachmentFilenames);
if (attachmentFilenames.length === 0) {
msg = getString("no-attachments-found");
msgType = "success";
} else {
for (const filename of attachmentFilenames) {
const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = {
collections: [collectionID],
file: filename,
};
await Zotero.Attachments.importFromFile(importOptions);
ztoolkit.log(`${filename} imported.`);
await actionAfterImport(filename);
}
msg = getString("import-attachments-success");
msgType = "success";
}
} catch (e) {
ztoolkit.log(e);
msg = String(e);
msgType = "fail";
} finally {
addon.data.isImportingAttachments = false;
new ztoolkit.ProgressWindow(config.addonName, {
closeOnClick: true,
closeTime: 1500,
})
.createLine({
text: msg,
type: msgType,
icon: `chrome://${config.addonRef}/content/icons/icon.png`,
})
.show();
}
}
/**
* 分类或选中的条目查找附件,从本地或远程下载。
* 注意,此处会过滤掉已有附件的条目。
* TODO: Exclude some attachment file types.
*/
export async function handleAttachmentMenu(menuType: "collection" | "item") {
let selectedItems: Zotero.Item[] = [];
if (menuType === "item") {
selectedItems = Zotero.getActiveZoteroPane().getSelectedItems();
} else if (menuType === "collection") {
const collectionID =
Zotero.getActiveZoteroPane().getSelectedCollection()?.id;
if (!collectionID) return;
selectedItems = Zotero.Collections.get(collectionID).getChildItems();
} else {
return;
}
const targetItemTypes = [
"journalArticle",
"thesis",
"book",
"bookSection",
"conferencePaper",
"report",
"patent",
];
const noAttachmentItems = selectedItems.filter((item) => {
if (!item.isRegularItem() || !targetItemTypes.includes(item.itemType))
return false;
// Exclude snapshot attachments
const aItems = item
.getAttachments()
.filter((i) => !Zotero.Items.get(i).isSnapshotAttachment());
ztoolkit.log(aItems);
return aItems.length === 0;
});
if (noAttachmentItems.length === 0) {
new ztoolkit.ProgressWindow(config.addonName, {
closeOnClick: true,
closeTime: 1500,
})
.createLine({
text: getString("no-item-need-attachment"),
type: "default",
icon: `chrome://${config.addonRef}/content/icons/cite.png`,
})
.show();
} else {
for (const item of noAttachmentItems) {
await addon.taskRunner.createAndAddTask(item, "local");
}
}
}
================================================
FILE: src/modules/translators.ts
================================================
import { getString } from "../utils/locale";
import { getPref, setPref } from "../utils/prefs";
export async function bestSpeedBaseUrl() {
const baseUrls = [
"https://oss.wwang.de/translators_CN",
"https://www.wieke.cn/translators_CN",
"https://ftp.zotero-chinese.com/translators_CN",
];
const testUrl = async (
url: string,
): Promise<{ url: string; time: number }> => {
const startTime = Date.now();
try {
await Zotero.HTTP.request("HEAD", `${url}/data/translators.json`, {
timeout: 5000,
});
const time = Date.now() - startTime;
ztoolkit.log(`${url} response time: ${time}ms`);
return { url, time };
} catch (error) {
ztoolkit.log(`${url} request failed: ${error}`);
return { url, time: Infinity };
}
};
const results = await Promise.all(baseUrls.map(testUrl));
const fastest = results.reduce((prev, curr) =>
curr.time < prev.time ? curr : prev,
);
ztoolkit.log(`use fastest base url: ${fastest.url} (${fastest.time}ms)`);
return fastest.url;
}
/**
* Get lastUpdated time from translator file
* @param filename translator filename with extension
* @returns lastUpdated time or false if failed
*/
export async function getLastUpdatedFromFile(
filename: string,
): Promise {
const desPath = PathUtils.join(
Zotero.DataDirectory.dir,
"translators",
filename,
);
const isFileExist = await IOUtils.exists(desPath);
if (isFileExist === false) {
ztoolkit.log(`get lastUpdated from file ${desPath} failed: file not exist`);
return false;
}
try {
// Assert source is a string in try block
const source = (await Zotero.File.getContentsAsync(desPath)) as string;
const infoRe = /^\s*{[\S\s]*?}\s*?[\r\n]/;
const metaData = JSON.parse(infoRe.exec(source)![0]);
ztoolkit.log(
`get lastUpdated from file ${desPath}: ${metaData.lastUpdated}`,
);
return metaData.lastUpdated;
} catch (error) {
ztoolkit.log(`get lastUpdated from file ${desPath} failed: ${error}`);
return false;
}
}
export async function getLastUpdatedMap(
refresh = true,
): Promise {
const cachePath = PathUtils.join(
Zotero.DataDirectory.dir,
"translators_CN.json",
);
if (refresh === false && (await IOUtils.exists(cachePath))) {
const contents = await Zotero.File.getContentsAsync(cachePath, "utf8");
ztoolkit.log(`translator data has been loaded from cache: ${cachePath}`);
return JSON.parse(contents as string);
}
try {
const baseUrl = getPref("translatorSource");
const contents = await Zotero.File.getContentsFromURLAsync(
`${baseUrl}/data/translators.json`,
);
ztoolkit.log(`translator data has been loaded from remote: ${baseUrl}`);
await Zotero.File.putContentsAsync(cachePath, contents);
return JSON.parse(contents);
} catch (event) {
ztoolkit.log(`getTranslatorsData failed: ${event}`);
return {};
}
}
async function mendTranslators() {
// Detect Endnote XML translator, if it's missing, it means the translators are broken, try to reset them.
// Return False if missing.
const endNoteTranslator = await Zotero.Translators.get(
"eb7059a4-35ec-4961-a915-3cf58eb9784b",
);
// 727 is the number of translators at the time of writing
if (
!getPref("firstRun") &&
!getPref("translatorsMended") &&
!endNoteTranslator
) {
ztoolkit.log(
"jasminum has been installed, and translators seems to be missing, try to reset them",
);
const reset = await Zotero.Schema.resetTranslators();
ztoolkit.log(`reset translators ${reset ? "successfully" : "failed"}`);
setPref("translatorsMended", true);
}
}
/**
* Download outdated translators from the source, with 12 hours interval by default.
*
* TODO: Download error when file is read-only in windows.
* @param force Whether ignore the time interval and force to download
*/
export async function updateTranslators(force = false): Promise {
if (addon.data.translators.updating) {
ztoolkit.log("translators are updating, skip this update");
return false;
}
try {
addon.data.translators.updating = true;
return await _updateTranslators(force);
} catch (error) {
return false;
} finally {
addon.data.translators.updating = false;
}
}
async function _updateTranslators(force = false): Promise {
await Zotero.Schema.schemaUpdatePromise;
await mendTranslators();
let needUpdate = false;
const lastUpdateTime = parseInt(getPref("translatorUpdateTime"));
const now = Date.now();
if (force == true || lastUpdateTime === undefined) {
ztoolkit.log(
`need to update translators, force: ${force}, lastUpdateTime: ${lastUpdateTime}`,
);
needUpdate = true;
} else {
if (now - lastUpdateTime > 1000 * 60 * 60 * 12) {
ztoolkit.log(
"need to update translators, it has been over 12 hours since the last update",
);
needUpdate = true;
} else {
ztoolkit.log(
"no need to update translators, it has been less than 12 hours since the last update",
);
}
}
if (needUpdate === false) return false;
const translatorData = await getLastUpdatedMap(needUpdate);
const baseUrl = getPref("translatorSource");
ztoolkit.log(`update translators from base: ${baseUrl}`);
const popupWin = new ztoolkit.ProgressWindow(getString("plugin-name"), {
closeOnClick: true,
closeTime: -1,
})
.createLine({
text: getString("update-translators-start"),
type: "default",
progress: 0,
})
.show();
const progressStep = 100 / Object.keys(translatorData).length;
let progress = 0;
let successCounts = 0;
let skipCounts = 0;
let failCounts = 0;
const translatorUpdateTasks = Object.keys(translatorData).map(
async (filename) => {
let type = "default",
text = "";
const localUpdateTime = await getLastUpdatedFromFile(filename);
const remoteUpdateTime = translatorData[filename].lastUpdated;
if (
localUpdateTime === false ||
new Date(remoteUpdateTime) > new Date(localUpdateTime)
) {
try {
const url = `${baseUrl}/${filename}`;
const code = await Zotero.File.getContentsFromURLAsync(url);
const desPath = PathUtils.join(
Zotero.DataDirectory.dir,
"translators",
filename,
);
await IOUtils.writeUTF8(desPath, code);
type = "success";
text = getString("update-successfully", {
args: { name: filename },
});
successCounts += 1;
} catch (error) {
type = "fail";
text = getString("update-failed", {
args: { name: filename },
});
failCounts += 1;
ztoolkit.log(`update translator ${filename} failed: ${error}`);
}
} else {
skipCounts += 1;
type = "default";
text = getString("update-skipped", {
args: { name: filename },
});
ztoolkit.log(`translator ${filename} is already up to date, skipped`);
}
progress += progressStep;
popupWin.changeLine({
type,
text,
progress,
});
},
);
await Promise.all(translatorUpdateTasks);
// @ts-ignore Translators is missing
await Zotero.Translators.reinit({ fromSchemaUpdate: false });
setPref("translatorUpdateTime", now.toString());
popupWin.changeLine({
text: getString("update-translators-complete", {
args: { successCounts, failCounts, skipCounts },
}),
type: "default",
progress: 100,
});
popupWin.startCloseTimer(3000);
ztoolkit.log(
`translators updated at ${new Date(now)}, success: ${successCounts}, skip: ${skipCounts}, fail: ${failCounts}`,
);
return true;
}
================================================
FILE: src/modules/workers/index.ts
================================================
import { test, addOutlineToPDF } from "./outline";
self.onmessage = async (e) => {
console.log("Minimal Worker收到:", e.data);
const data = e.data;
if (data && data.action === "test") {
const result = test(data.title);
self.postMessage({
action: "testReturn",
jobID: data.jobID,
status: "success",
result,
});
} else if (data && data.action === "addOutline") {
const { filePath, outlineNodes } = data;
await addOutlineToPDF(filePath, outlineNodes);
self.postMessage({
action: "addOutlineReturn",
jobID: data.jobID,
status: "success",
});
}
};
================================================
FILE: src/modules/workers/outline.ts
================================================
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFNull,
PDFNumber,
PDFPageLeaf,
PDFRef,
} from "pdf-lib";
export function test(title: string) {
const startTimestamp = Date.now();
let result = title;
for (let i = 0; i < 100; i++) {
result = result
.split("")
.map((c) => String.fromCharCode(c.charCodeAt(0) + 1))
.join("");
}
const endTimestamp = Date.now();
const time = endTimestamp - startTimestamp;
return { result, time };
}
function prepareData(
outlineNodes: OutlineNode[],
pdfDoc: PDFDocument,
): [OutlineNode[], number] {
let counts = 0;
outlineNodes.forEach((node: OutlineNode) => {
node.ref = pdfDoc.context.nextRef();
if (node.children && node.children.length > 0) {
node.children = prepareData(node.children, pdfDoc)[0];
counts = counts + 1;
}
});
return [outlineNodes, counts];
}
function createOutlineItem(
pdfDoc: PDFDocument,
node: OutlineNode,
parentRef: PDFRef,
prev: PDFRef | null,
next: PDFRef | null,
page: PDFRef,
) {
const outlineItemDictMap = new Map();
outlineItemDictMap.set(PDFName.Title, PDFHexString.fromText(node.title));
outlineItemDictMap.set(PDFName.Parent, parentRef);
if (node.children && node.children.length > 0) {
outlineItemDictMap.set(PDFName.of("First"), node.children[0].ref);
outlineItemDictMap.set(
PDFName.of("Last"),
node.children[node.children.length - 1].ref,
);
outlineItemDictMap.set(
PDFName.of("Count"),
PDFNumber.of(node.children.length),
);
}
if (prev != null) {
outlineItemDictMap.set(PDFName.of("Prev"), prev);
}
if (next != null) {
outlineItemDictMap.set(PDFName.of("Next"), next);
}
// Set the destination
const array = PDFArray.withContext(pdfDoc.context);
array.push(page);
array.push(PDFName.of("XYZ"));
array.push(PDFNumber.of(node.x)); // X
array.push(PDFNumber.of(node.y)); // Y
array.push(PDFNull); // Zoom
outlineItemDictMap.set(PDFName.of("Dest"), array);
const outlineItem = PDFDict.fromMapWithContext(
outlineItemDictMap,
pdfDoc.context,
);
pdfDoc.context.assign(node.ref, outlineItem);
console.log(`Outline item dict: ${node.level}, ${node.title}`);
}
function createOutlineDict(
outlineNodes: OutlineNode[],
counts: number,
pdfDoc: PDFDocument,
): PDFDict {
const outlinesDictMap = new Map();
outlinesDictMap.set(PDFName.Type, PDFName.of("Outlines"));
outlinesDictMap.set(PDFName.of("First"), outlineNodes[0].ref!);
outlinesDictMap.set(
PDFName.of("Last"),
outlineNodes[outlineNodes.length - 1].ref!,
);
outlinesDictMap.set(PDFName.of("Count"), PDFNumber.of(counts));
return PDFDict.fromMapWithContext(outlinesDictMap, pdfDoc.context);
}
export async function addOutlineToPDF(
pdfPath: string,
outlineNodes: OutlineNode[],
) {
const pdfBytes = await IOUtils.read(pdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
// PDF Page reference
const pageRefs: PDFRef[] = [];
pdfDoc.catalog.Pages().traverse((kid, ref) => {
if (kid instanceof PDFPageLeaf) pageRefs.push(ref);
});
const rootRef = pdfDoc.context.nextRef();
const [preparedOutlineNodes, totalCounts] = prepareData(outlineNodes, pdfDoc);
// Create outline item dict
const outlinesDict = createOutlineDict(
preparedOutlineNodes,
totalCounts,
pdfDoc,
);
//Pointing the "Outlines" property of the PDF's "Catalog" to the first object of your outlines
pdfDoc.catalog.set(PDFName.of("Outlines"), rootRef);
//First 'Outline' object. Refer to table H.3 in Annex H.6 of PDF Specification doc.
pdfDoc.context.assign(rootRef, outlinesDict);
console.log("Prepared outline nodes: ", preparedOutlineNodes);
// Add outline item dict
const loop = (nodes: OutlineNode[]) => {
nodes.forEach((node: OutlineNode, idx: number) => {
// Create outline item dict
createOutlineItem(
pdfDoc,
node,
node.level === 1 ? rootRef : node.ref,
idx > 0 ? nodes[idx - 1].ref : null,
idx < nodes.length - 1 ? nodes[idx + 1].ref : null,
pageRefs[node.page - 1],
);
if (node.children && node.children.length > 0) {
const children = node.children;
loop(children);
}
});
};
loop(preparedOutlineNodes);
const pdfBytesWithOutline = await pdfDoc.save();
await IOUtils.write(pdfPath, pdfBytesWithOutline);
console.log("Add outline to pdf complete.");
}
================================================
FILE: src/modules/wps.ts
================================================
async function unZip(filename: string, outDir: string) {
ztoolkit.log(outDir, filename);
const zipFile = Zotero.File.pathToFile(filename);
// @ts-ignore -- Not typed.
const zipReader = Components.classes[
"@mozilla.org/libjar/zip-reader;1"
].createInstance(Components.interfaces.nsIZipReader);
zipReader.open(zipFile);
// Extract files
const entries = zipReader.findEntries("*");
const subfolders = new Set();
const entryFiles: any = {};
while (entries.hasMore()) {
const entry = entries.getNext();
// Unix Mac Windows, path seperator.
const pathParts = entry.split(/[/\\]/);
if (pathParts.length > 1)
subfolders.add(PathUtils.join(outDir, pathParts.slice(0, -1)));
if (entry.endsWith("/") || entry.endsWith("\\")) {
continue;
}
entryFiles[entry] = PathUtils.join(outDir, pathParts);
}
for (const e of subfolders) {
ztoolkit.log("Create subfolder: " + e);
await IOUtils.makeDirectory(e, { ignoreExisting: true });
ztoolkit.log(`${await IOUtils.exists(e)}`);
}
Object.keys(entryFiles).forEach((e) => {
ztoolkit.log(e, entryFiles[e]);
zipReader.extract(e, Zotero.File.pathToFile(entryFiles[e]));
});
zipReader.close();
}
export async function downloadWpsPlugin() {
const baseDir = PathUtils.join(Zotero.DataDirectory.dir, "jasminum");
const wpsFolder = PathUtils.join(baseDir, "wps");
const unzipFolder = PathUtils.join(wpsFolder, "unzip");
const zipFilename = PathUtils.join(wpsFolder, "wps.zip");
await IOUtils.makeDirectory(unzipFolder, {
ignoreExisting: true,
createAncestors: true,
});
const wpsUrl = "https://ftp.linxingzhong.top/";
const tmpContent = await Zotero.File.getContentsFromURLAsync(wpsUrl);
await Zotero.File.putContentsAsync(zipFilename, tmpContent);
ztoolkit.log("WPS plugins download complete");
await unZip(zipFilename, unzipFolder);
ztoolkit.log("Unzip completed. " + unzipFolder);
}
export async function installWpsPlugin() {
let runStatus: true | Error;
if (Zotero.isWin) {
runStatus = await Zotero.Utilities.Internal.exec("安装.exe", []);
} else {
runStatus = await Zotero.Utilities.Internal.exec("python", ["install.py"]);
}
if (runStatus == true) {
ztoolkit.log("Install completed.");
} else {
ztoolkit.log("Install errors", runStatus);
}
}
================================================
FILE: src/utils/cookiebox.ts
================================================
export class MyCookieSandbox {
public searchCookieBox: Zotero.CookieSandbox | null = null;
// public attachmentCookieBox: Zotero.CookieSandbox | null = null;
// public refCookieBox: Zotero.CookieSandbox | null = null;
userAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36";
baseUrl = "https://www.cnki.net";
private _CNKIHomeCookieBox: Zotero.CookieSandbox | null = null;
private _cnkiHomeCookieLastUpdateTime: number = 0;
private _initPromise: Promise | null = null;
private _captchaPromise: Promise | null = null;
private static readonly COOKIE_EXPIRE_MS = 5 * 60 * 1000; // 10 minutes
constructor() {
this._CNKIHomeCookieBox = null;
}
public async getCookieBoxFromUrl(
url: string,
hintText: string = "请完成验证码,验证成功后,点击此按钮",
): Promise {
// @ts-ignore - Not typed.
const cookieSandbox = new Zotero.CookieSandbox();
ztoolkit.log("Opening URL in viewer: " + url);
const win = Zotero.openInViewer(url, {
cookieSandbox: cookieSandbox,
}) as any as Window;
return new Promise((resolve, reject) => {
let promiseSettled = false;
let cookieRetrieved = false;
win.addEventListener("close", function () {
ztoolkit.log("Window closed");
if (!promiseSettled) {
promiseSettled = true;
if (cookieRetrieved) {
ztoolkit.log("Cookie sandbox returned successfully");
resolve(cookieSandbox);
} else {
ztoolkit.log("Window closed without retrieving cookies");
reject(new Error(`用户关闭窗口,未完成验证: ${url}`));
}
}
});
win.addEventListener("load", function () {
ztoolkit.log("Window loaded, adding button");
const buttonContainer = ztoolkit.UI.createElement(win.document, "box", {
namespace: "html",
attributes: { id: "captcha-button-container" },
styles: {
position: "fixed",
top: "10px",
right: "10px",
zIndex: "10000",
padding: "15px",
backgroundColor: "white",
border: "3px solid red",
borderRadius: "8px",
boxShadow: "0 4px 8px rgba(0,0,0,0.3)",
cursor: "pointer",
userSelect: "none",
transition: "left 0.3s ease, right 0.3s ease",
},
});
let isOnRight = true;
const titleLabel = ztoolkit.UI.createElement(win.document, "label", {
namespace: "html",
attributes: { value: "茉莉花提示:" },
styles: {
fontWeight: "bold",
color: "black",
fontSize: "14px",
marginBottom: "5px",
display: "block",
},
});
const hintLabel = ztoolkit.UI.createElement(
win.document,
"description",
{
namespace: "html",
properties: { textContent: hintText },
styles: {
color: "black",
fontSize: "12px",
marginBottom: "10px",
lineHeight: "1.5",
maxWidth: "250px",
whiteSpace: "normal",
wordWrap: "break-word",
},
},
);
const positionHint = ztoolkit.UI.createElement(
win.document,
"description",
{
namespace: "html",
properties: { textContent: "(双击此框可切换左右位置)" },
styles: {
color: "#666",
fontSize: "10px",
marginBottom: "8px",
fontStyle: "italic",
},
},
);
const button = ztoolkit.UI.createElement(win.document, "button", {
namespace: "html",
properties: { textContent: "确认完成验证" },
styles: {
fontSize: "12px",
padding: "4px",
cursor: "pointer",
backgroundColor: "#4CAF50",
background: "#4CAF50",
color: "black",
border: "none",
borderRadius: "5px",
width: "50%",
fontWeight: "bold",
},
});
button.addEventListener("mouseover", function () {
if (!button.disabled) {
button.style.backgroundColor = "#45a049";
button.style.background = "#45a049";
}
});
button.addEventListener("mouseout", function () {
if (!button.disabled) {
button.style.backgroundColor = "#4CAF50";
button.style.background = "#4CAF50";
}
});
button.addEventListener("click", function () {
try {
const uri = Services.io.newURI(url);
const cookies = cookieSandbox.getCookiesForURI(uri);
ztoolkit.log("Cookies retrieved from sandbox.", cookies);
if (cookies) {
for (const name in cookies) {
ztoolkit.log(` ${name} = ${cookies[name]}`);
}
cookieRetrieved = true;
if (!promiseSettled) {
promiseSettled = true;
resolve(cookieSandbox);
ztoolkit.log("Promise resolved with cookieSandbox");
}
win.close();
ztoolkit.log("Cookies retrieved successfully.");
} else {
ztoolkit.log("未找到 cookies");
button.setAttribute("label", "✗ 未找到 Cookie");
button.style.backgroundColor = "#f44336";
button.style.color = "white";
hintLabel.textContent = "未找到 Cookie,请确保已完成验证";
hintLabel.style.color = "#f44336";
}
} catch (e: any) {
ztoolkit.log("获取 cookie 时出错: " + e);
button.setAttribute("label", "✗ 出错了");
button.style.backgroundColor = "#f44336";
button.style.color = "white";
hintLabel.textContent = "出错了: " + e.message;
hintLabel.style.color = "#f44336";
}
});
buttonContainer.appendChild(titleLabel);
buttonContainer.appendChild(hintLabel);
buttonContainer.appendChild(positionHint);
buttonContainer.appendChild(button);
buttonContainer.addEventListener("dblclick", function (e) {
if (
e.target === button ||
(e.target as HTMLElement).closest("button")
) {
return;
}
if (isOnRight) {
buttonContainer.style.right = "auto";
buttonContainer.style.left = "10px";
isOnRight = false;
ztoolkit.log("Button moved to left");
} else {
buttonContainer.style.left = "auto";
buttonContainer.style.right = "10px";
isOnRight = true;
ztoolkit.log("Button moved to right");
}
});
const browserBox = win.document.getElementById("browser");
if (browserBox) {
browserBox.appendChild(buttonContainer);
} else {
win.document.documentElement.appendChild(buttonContainer);
}
ztoolkit.log("Button with position toggle added successfully");
});
});
}
public async getCNKIHomeCookieBox(): Promise {
const now = Date.now();
const isExpired =
now - this._cnkiHomeCookieLastUpdateTime >
MyCookieSandbox.COOKIE_EXPIRE_MS;
// If cookie exists and not expired, return directly
if (this._CNKIHomeCookieBox != null && !isExpired) {
return this._CNKIHomeCookieBox;
}
// Cookie expired or missing, reset for re-initialization
if (isExpired && this._CNKIHomeCookieBox != null) {
ztoolkit.log("CNKI Home cookie expired, re-initializing...");
this._CNKIHomeCookieBox = null;
this._initPromise = null;
}
if (!this._initPromise) {
ztoolkit.log("homeCookieBox 为空,开始初始化...");
this._initPromise = this.getCookieBoxFromUrl(
"https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=%E7%A7%91%E7%A0%94%E8%AE%BA%E6%96%87%E9%98%85%E8%AF%BB",
"请等待知网网页正常打开后,再点击下方按钮关闭",
).then((cookieSandbox) => {
this._CNKIHomeCookieBox = cookieSandbox;
this._cnkiHomeCookieLastUpdateTime = Date.now();
});
}
await this._initPromise;
// 保险起见,再次检查是否成功获取到 cookieSandbox
if (this._CNKIHomeCookieBox == null) {
ztoolkit.log("homeCookieBox 还是为空,又开始初始化...");
this._CNKIHomeCookieBox = await this.getCookieBoxFromUrl(
"https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=%E7%A7%91%E7%A0%94%E8%AE%BA%E6%96%87%E9%98%85%E8%AF%BB",
"请等待知网网页正常打开后,再点击下方按钮关闭",
);
this._cnkiHomeCookieLastUpdateTime = Date.now();
}
return this._CNKIHomeCookieBox!;
}
async passCaptchaToCookieBox(
url: string,
cookieType:
| "CNKI:Search"
| "CNKI:Attachment"
| "CNKI:Reference"
| "CNKI:Home",
): Promise {
// 如果已经有验证码窗口在运行,等待它完成
if (this._captchaPromise) {
ztoolkit.log(
"Captcha window is already running, waiting for it to complete...",
);
return this._captchaPromise;
}
this._captchaPromise = this.getCookieBoxFromUrl(url).then(
(cookieSandbox) => {
// 根据 cookieType 设置对应的 cookieSandbox
switch (cookieType) {
case "CNKI:Home":
addon.data.myCookieSandbox._CNKIHomeCookieBox = cookieSandbox;
addon.data.myCookieSandbox._cnkiHomeCookieLastUpdateTime =
Date.now();
break;
// 其他类型...
}
ztoolkit.log("Cookies passed to addon CookieSandbox.");
return cookieSandbox;
},
);
// 在 Promise 完成后清空,无论成功还是失败
this._captchaPromise.finally(() => {
this._captchaPromise = null;
ztoolkit.log("Captcha promise cleared, ready for next captcha request");
});
return this._captchaPromise;
}
}
================================================
FILE: src/utils/detect.ts
================================================
// 这里有许多类型判断,判断不同的条目类型
/**
* 主要检测知网等其他数据库下载的附件文件名是否至少有3个汉字
* Created by DeepSeek
* @param filename
* @returns
*/
const CHINESE_FILENAME_REGEX =
/^(?=(.*?\p{Unified_Ideograph}){3})(?=(.*\p{Unified_Ideograph}){3}).+\.(pdf|caj|kdh|nh)$/iu;
export function isChineseAttachmentFilename(filename: string): boolean {
return CHINESE_FILENAME_REGEX.test(filename);
}
/**
* Return true when item is a top level Chinese PDF/CAJ item.
*/
export function isChineseTopAttachment(item: Zotero.Item): boolean {
return (
item.isAttachment() &&
item.isTopLevelItem() &&
isChineseAttachmentFilename(item.attachmentFilename)
);
}
/**
* 检测是否是中文的顶层条目
* @param item
* @returns
*/
export function isChineseTopItem(item: Zotero.Item): boolean {
return (
item.isRegularItem() &&
item.isTopLevelItem() &&
/\p{Unified_Ideograph}/iu.test(item.getField("title"))
);
}
/**
* CNKI Snapshot attachment item,注意是附件条目
* CNKI Webpage top level item. 注意是网页类型条目
* @param item
* @returns
*/
export function isChinsesSnapshot(item: Zotero.Item): boolean {
return (
(item.isSnapshotAttachment() &&
item.getField("title").includes("- 中国知网")) ||
(item.isTopLevelItem() &&
item.itemType == "webpage" &&
item.getField("title").includes("- 中国知网"))
);
}
================================================
FILE: src/utils/http.ts
================================================
function jsonToFormUrlEncoded(json: any) {
return Object.keys(json)
.map(
(key) =>
encodeURIComponent(key) +
"=" +
encodeURIComponent(
typeof json[key] === "object" ? JSON.stringify(json[key]) : json[key],
),
)
.join("&");
}
async function requestDocument(
url: string,
options?: {
method?: string;
body?: string;
headers?: any;
responseType?: string;
responseCharset?: string;
successCodes?: number[] | false;
cookieSandbox?: Zotero.CookieSandbox;
},
): Promise {
const xhr = await Zotero.HTTP.request(options?.method || "GET", url, {
...options,
responseType: "document",
});
let doc = xhr.response;
if (doc && !doc.location) {
doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL);
}
return doc;
}
function text2HTMLDoc(text: string, url?: string): Document {
let doc = new DOMParser().parseFromString(text, "text/html");
if (url) {
doc = Zotero.HTTP.wrapDocument(doc, url);
}
return doc;
}
// Detect user is in mainland China.
// Except 中国台湾,中国香港,中国澳门
async function isMainlandChina(): Promise {
const mainlandChina = [
"浙江省",
"江苏省",
"广东省",
"山东省",
"河南省",
"四川省",
"湖北省",
"河北省",
"湖南省",
"安徽省",
"辽宁省",
"福建省",
"陕西省",
"黑龙江省",
"吉林省",
"山西省",
"江西省",
"云南省",
"贵州省",
"内蒙古自治区",
"广西壮族自治区",
"西藏自治区",
"宁夏回族自治区",
"新疆维吾尔自治区",
"北京市",
"天津市",
"上海市",
"重庆市",
];
const html = await requestDocument("https://ip.chinaz.com/", {
method: "GET",
});
const targets = Zotero.Utilities.xpath(
html,
"//div[contains(text(), '您的本机IP地址')]",
);
if (targets.length > 0) {
const targetContent = targets[0].textContent;
return mainlandChina.some((p) => targetContent?.includes("归属地:" + p));
}
return true;
}
/**
* A simple HTML selector and attribute extractor.
*/
class DocTools {
private node: Document | Element;
constructor(node: Document | Element) {
this.node = node;
}
attr(selector: string, attr: string, index?: number): string {
const elm = this.choose(selector, index);
return elm && elm.hasAttribute(attr) ? elm.getAttribute(attr)!.trim() : "";
}
text(selector: string, index?: number): string {
const elm = this.choose(selector, index);
return elm && elm.textContent ? elm.textContent!.trim() : "";
}
innerText(selector: string, index?: number): string {
const elm = this.choose(selector, index);
return elm && elm.textContent ? elm.textContent.trim() : "";
}
choose(selector: string, index?: number): Element | null {
if (index === undefined) {
return this.node.querySelector(selector);
} else {
const items = this.node.querySelectorAll(selector);
if (index >= 0) {
return items.item(index);
} else {
return items.item(items.length + index);
}
}
}
}
export {
requestDocument,
jsonToFormUrlEncoded,
isMainlandChina,
DocTools,
text2HTMLDoc,
};
================================================
FILE: src/utils/locale.ts
================================================
import { config } from "../../package.json";
export { initLocale, getString, getLocaleID };
/**
* Initialize locale data
*/
function initLocale() {
const l10n = new (
typeof Localization === "undefined"
? ztoolkit.getGlobal("Localization")
: Localization
)([`${config.addonRef}-addon.ftl`], true);
addon.data.locale = {
current: l10n,
};
}
/**
* Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl
* @param localString ftl key
* @param options.branch branch name
* @param options.args args
* @example
* ```ftl
* # addon.ftl
* addon-static-example = This is default branch!
* .branch-example = This is a branch under addon-static-example!
* addon-dynamic-example =
{ $count ->
[one] I have { $count } apple
*[other] I have { $count } apples
}
* ```
* ```js
* getString("addon-static-example"); // This is default branch!
* getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example!
* getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple
* getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples
* ```
*/
function getString(localString: string): string;
function getString(localString: string, branch: string): string;
function getString(
localeString: string,
options: { branch?: string | undefined; args?: Record },
): string;
function getString(...inputs: any[]) {
if (inputs.length === 1) {
return _getString(inputs[0]);
} else if (inputs.length === 2) {
if (typeof inputs[1] === "string") {
return _getString(inputs[0], { branch: inputs[1] });
} else {
return _getString(inputs[0], inputs[1]);
}
} else {
throw new Error("Invalid arguments");
}
}
function _getString(
localeString: string,
options: { branch?: string | undefined; args?: Record } = {},
): string {
const localStringWithPrefix = `${config.addonRef}-${localeString}`;
const { branch, args } = options;
const pattern = addon.data.locale?.current.formatMessagesSync([
{ id: localStringWithPrefix, args },
])[0];
if (!pattern) {
return localStringWithPrefix;
}
if (branch && pattern.attributes) {
for (const attr of pattern.attributes) {
if (attr.name === branch) {
return attr.value;
}
}
return pattern.attributes[branch] || localStringWithPrefix;
} else {
return pattern.value || localStringWithPrefix;
}
}
function getLocaleID(id: string) {
return `${config.addonRef}-${id}`;
}
================================================
FILE: src/utils/pattern.ts
================================================
export function getArgsFromPattern(
filename: string,
pattern: string,
): SearchOption | null {
// Make query parameters from filename
const prefix = filename
.replace(/\.\w+$/, "") // 删除文件后缀
.replace(/\.ashx$/g, "") // 删除末尾.ashx字符
.replace(/^_|_$/g, "") // 删除前后的下划线
.replace(/[((]\d+[))]$/, "") // 删除重复下载时文件名出现的数字编号 (1) (1)
.trim();
// 当文件名模板为"{%t}_{%g}",文件名无下划线_时,将文件名认定为标题
if (pattern === "{%t}_{%g}" && !prefix.includes("_")) {
return {
author: "",
title: prefix,
};
}
const patternSepArr: string[] = pattern.split(/{%[^}]+}/);
const patternSepRegArr: string[] = patternSepArr.map((x) =>
x.replace(/([[^$.|?*+()])/g, "\\$&"),
);
const patternMainArr: string[] | null = pattern.match(/{%[^}]+}/g);
//文件名中的作者姓名字段里不能包含下划线,请使用“&,,”等字符分隔多个作者,或仅使用第一个作者名(加不加“等”都行)。
const patternMainRegArr = patternMainArr!.map((x) =>
x.replace(
/.+/,
/{%y}/.test(x) ? "(\\d+)" : /{%g}/.test(x) ? "([^_]+)" : "(.+)",
),
);
const regStrInterArr = patternSepRegArr.map((_, i) => [
patternSepRegArr[i],
patternMainRegArr[i],
]);
const patternReg = new RegExp(
// eslint-disable-next-line prefer-spread
[].concat
.apply([], regStrInterArr as never)
.filter(Boolean)
.join(""),
"g",
);
const prefixMainArr = patternReg.exec(prefix);
// 文件名识别结果为空,跳出警告弹窗
if (prefixMainArr === null) {
return null;
}
const titleIdx = patternMainArr!.indexOf("{%t}");
const authorIdx = patternMainArr!.indexOf("{%g}");
const titleRaw = titleIdx != -1 ? prefixMainArr[titleIdx + 1] : "";
const authors = authorIdx != -1 ? prefixMainArr[authorIdx + 1] : "";
const authorArr = authors.split(/[,,&]/);
let author = authorArr[0];
if (authorArr.length == 1) {
//删除名字后可能出现的“等”字,此处未能做到识别该字是否属于作者姓名。
//这种处理方式的问题:假如作者名最后一个字为“等”,例如:“刘等”,此时会造成误删。
//于是对字符数进行判断,保证删除“等”后,至少还剩两个字符,尽可能地避免误删。
author =
author.endsWith("等") && author.length > 2
? author.substring(0, author.length - 1)
: author;
}
//为了避免文件名中的标题字段里存在如下两种情况而导致的搜索失败:
//原标题过长,文件名出现“_省略_”;
//原标题有特殊符号(如希腊字母、上下标)导致的标题变动,此时标题也会出现“_”。
//于是只取用标题中用“_”分割之后的最长的部分作为用于搜索的标题。
//这种处理方式的问题:假如“最长的部分”中存在知网改写的部分,也可能搜索失败。
//不过这只是理论上可能存在的情形,目前还未实际遇到。
let title: string;
// Zotero.debug(titleRaw);
// if (/_/.test(titleRaw)) {
// //getLongestText函数,用于拿到字符串数组中的最长字符
// //摘自https://stackoverflow.com/a/59935726
// const getLongestText = (arr) => arr.reduce(
// (savedText, text) => (text.length > savedText.length ? text : savedText),
// '',
// );
// title = getLongestText(titleRaw.split(/_/));
// } else {
// title = titleRaw;
// }
// 去除_省略_ "...", 多余的 _ 换为空格
// 标题中含有空格,查询时会启用模糊模式
title = titleRaw.replace("_省略_", " ").replace("...", " ");
title = title.replace(/_/g, " ");
return {
author: author,
title: title,
};
}
================================================
FILE: src/utils/pdfParser.ts
================================================
async function getPDFTitle(itemID: number): Promise {
// @ts-ignore - PDFWorker is not typed
const recognizerData = await Zotero.PDFWorker.getRecognizerData(itemID, true);
ztoolkit.log("recognizerData: ", debugDoc(recognizerData));
const pdfData = recognizerDataToPdfData(recognizerData);
ztoolkit.log("pdfData: ", pdfData);
const docType = detectDocType(pdfData);
ztoolkit.log("docType: ", docType);
/*
* 更好的做法是,仅将属性名称语义化的 PDF 数据传递给 get*() 函数,
* 由函数内部根据各文献类型的排版特点对数据进行重新组织。
*/
switch (docType) {
case "article":
return getArticleTitle(pdfData);
case "thesis":
return getThesisTitle(pdfData);
}
return "";
}
function isValidTitle(line: PdfParagraph): boolean {
return (
line.classList.length === 0 && line.text.length > 3 && hasCJK(line.text)
);
}
function getThesisTitle(data: PdfData): string {
const contextLine = findParagraphInPagesReversed(data.pages, (pages) =>
findParagraphAfter(pages.paragraphs, keyPatterns.thesis["bfore-title"]),
);
const maxSizeLine = findMaxSizeParagraph(
data.pages.flatMap((page) => page.paragraphs),
);
return (contextLine?.text ?? maxSizeLine?.text ?? "").replace(
/^(论文)?(颗|题)目((.+?))?:?/,
"",
);
}
function getArticleTitle(data: PdfData): string {
let mainPage = data.pages[0];
if (/《.+》网络首发论文/.test(mainPage.text)) {
ztoolkit.log("CNKI advanced online article");
mainPage = data.pages[1];
}
return (findMaxSizeParagraph(mainPage.paragraphs)?.text ?? "").replace(
new RegExp(`[${footnoteMarkers}]+$`),
"",
);
}
function findParagraphInPages(
pages: PdfPage[],
finder: (
page: PdfPage,
index: number,
pages: PdfPage[],
) => PdfParagraph | undefined,
): PdfParagraph | undefined {
for (let i = pages.length - 1; i >= 0; i--) {
const paragraph = finder(pages[i], i, pages);
if (paragraph !== undefined) {
return paragraph;
}
}
return undefined;
}
function findParagraphInPagesReversed(
pages: PdfPage[],
finder: (
page: PdfPage,
index: number,
pages: PdfPage[],
) => PdfParagraph | undefined,
): PdfParagraph | undefined {
for (let i = 0; i < pages.length; i++) {
const paragraph = finder(pages[i], i, pages);
if (paragraph !== undefined) {
return paragraph;
}
}
return undefined;
}
function findParagraphAfter(paragraphs: PdfParagraph[], patterns: RegExp[]) {
return paragraphs.findLast((paragraph, index, paragraphs) => {
const anchorParagraph: PdfParagraph | undefined = paragraphs[index - 1];
return (
isValidTitle(paragraph) &&
anchorParagraph &&
patterns.some((regexp) => regexp.test(anchorParagraph.text))
);
});
}
function findParagraphBefore(paragraphs: PdfParagraph[], patterns: RegExp[]) {
return paragraphs.find((paragraph, index, paragraphs) => {
const anchorParagraph: PdfParagraph | undefined = paragraphs[index + 1];
return (
isValidTitle(paragraph) &&
anchorParagraph &&
patterns.some((regexp) => regexp.test(anchorParagraph.text))
);
});
}
function findMaxSizeParagraph(paragraphs: PdfParagraph[]) {
let candidateParagraph: PdfParagraph | undefined;
for (const paragraph of paragraphs) {
if (isValidTitle(paragraph)) {
if (
!candidateParagraph ||
parseFloat(paragraph.fontSize) > parseFloat(candidateParagraph.fontSize)
) {
candidateParagraph = paragraph;
}
}
}
return candidateParagraph;
}
function detectDocType(data: PdfData): DocType {
const hitsCounter = {
article: 0,
thesis: 0,
book: 0,
};
if (data.totalPages > 10) {
pageLoop: for (const page of data.pages) {
for (const paragraph of page.paragraphs) {
if (breakMarks.some((regexp) => regexp.test(paragraph.text))) {
ztoolkit.log("stop at paragraph: ", paragraph.text);
break pageLoop;
}
typeLoop: for (const key in docTypePatterns) {
const docType = key as DocType;
for (const pattern of docTypePatterns[docType]) {
if (pattern.test(paragraph.text)) {
ztoolkit.log(paragraph.text, "hits", pattern);
hitsCounter[docType]++;
if (hitsCounter[docType] > 3) {
return docType;
}
break typeLoop;
}
}
}
}
}
}
ztoolkit.log(hitsCounter);
return Object.values(hitsCounter).some((count) => count > 0)
? Object.keys(hitsCounter)
.map((key) => key as DocType)
.reduce((a, b) => (hitsCounter[a] > hitsCounter[b] ? a : b))
: "article";
}
function sortLines(lines: PdfLine[]): PdfLine[] {
return lines.sort((lineA, lineB) => {
if (lineA.baseline == lineB.baseline) {
return lineA.xMin - lineB.xMin;
}
return lineA.baseline - lineB.baseline;
});
}
function recognizerDataToPdfData(data: RecognizerData): PdfData {
return {
metadata: data.metadata,
totalPages: data.totalPages,
pages: data.pages.map((page) => recognizerPageToPdfPage(page)),
};
}
function recognizerPageToPdfPage(page: RecognizerPage): PdfPage {
const lines = page[2][0][0][0][4].map(recognizerLineToPdfLine);
// 这里会将双烂排序的段落打乱,甚至将尾注混合在正文段落中,但我们不关心这部分信息。
// 以后如果需要获取更细粒度的信息,需要对行盒子的位置关系进行比较进行多次分组和排序。
// 我已经对此进行了测试,但复杂度和性能都不太好。
// 考虑AI识别的普遍应用,将来可能会有更好的解决方案。
const paragraphs = pdfLinesToPdfParagraphs(sortLines(lines));
return {
width: page[0],
height: page[1],
text: paragraphs.map((paragraph) => paragraph.text).join("\n"),
classList: [],
paragraphs,
};
}
function pdfLinesToPdfParagraphs(lines: PdfLine[]): PdfParagraph[] {
const paragraphs: PdfParagraph[] = [];
for (let i = 0; i < lines.length; i++) {
const preLine = lines[i - 1];
const curLine = lines[i];
const paragraph = paragraphs.at(-1);
if (!paragraph) {
paragraphs.push({
fontSize: curLine.fontSize,
text: curLine.text,
classList: [],
lines: [curLine],
});
} else {
const fontSizeEqual = preLine.fontSize === curLine.fontSize;
const semanticCoherence =
/\S([、,:~,:&]|—{1,2})$/iu.test(preLine.text) ||
/^—{1,2}\S/iu.test(curLine.text);
function typographicConsistency() {
function getFontIndexies(words: PdfWord[]) {
return Array.from(new Set(words.map((word) => word.fontIndex)));
}
if (hasCJK(preLine.text) && hasCJK(curLine.text)) {
const preFontIndexies = getFontIndexies(
preLine.words.filter((word) => hasCJK(word.text)),
);
const curFontIndexies = getFontIndexies(
curLine.words.filter((word) => hasCJK(word.text)),
);
return curFontIndexies.some((fontIndex) =>
preFontIndexies.includes(fontIndex),
);
} else if (!hasCJK(preLine.text) && !hasCJK(curLine.text)) {
const preFontIndexies = getFontIndexies(
preLine.words.filter((word) => !hasCJK(word.text)),
);
const curFontIndexies = getFontIndexies(
curLine.words.filter((word) => !hasCJK(word.text)),
);
return curFontIndexies.some((fontIndex) =>
preFontIndexies.includes(fontIndex),
);
}
return false;
}
if ((fontSizeEqual || semanticCoherence) && typographicConsistency()) {
paragraph.lines.push(curLine);
} else {
paragraph.fontSize = preLine.fontSize;
let text = "";
if (paragraph.lines.length === 1) {
text = paragraph.lines[0].text;
} else if (paragraph.lines.length > 1) {
paragraph.lines.reduce((pre, cur) => {
let delimiter = "";
const wordAndWord = /\w$/.test(pre.text) && /^\w/.test(cur.text);
const punctuationAndWord =
/[!$%&\]);:,.>]$/iu.test(pre.text) && /^\w/.test(cur.text);
if (wordAndWord || punctuationAndWord) {
delimiter = " ";
}
text += `${pre.text}${delimiter}${cur.text}`;
return cur;
});
}
paragraph.text = text;
paragraphs.push({
fontSize: curLine.fontSize,
text: curLine.text,
classList: [],
lines: [curLine],
});
}
}
}
return paragraphs;
}
function recognizerLineToPdfLine(line: RecognizerLine): PdfLine {
const words = line[0].map(recognizerWordToPdfWord);
return pdfWordsToPdfLine(words);
}
function pdfWordsToPdfLine(words: PdfWord[]): PdfLine {
const CJKWords = words.filter((word) => hasCJK(word.text));
const fontSize = average(
CJKWords.length ? CJKWords : words,
(word) => word.fontSize,
).toFixed(2);
return {
xMin: Math.min(...words.map((word) => word.xMin)),
yMin: Math.min(...words.map((word) => word.yMin)),
xMax: Math.max(...words.map((word) => word.xMax)),
yMax: Math.max(...words.map((word) => word.yMax)),
fontSize,
baseline: average(words, (word) => word.baseline),
text: normalizeText(
words.map((word) => `${word.text}${word.spaceAfter ? " " : ""}`).join(""),
),
words,
};
}
function recognizerWordToPdfWord(word: RecognizerWord): PdfWord {
return {
xMin: word[0],
yMin: word[1],
xMax: word[2],
yMax: word[3],
fontSize: word[4],
spaceAfter: Boolean(word[5]),
baseline: word[6],
rotation: Boolean(word[7]),
underlined: Boolean(word[8]),
bold: Boolean(word[9]),
italic: Boolean(word[10]),
colorIndex: word[11],
fontIndex: word[12],
text: word[13],
};
}
function average(arr: T[], callback: (arg: T) => number): number {
return arr.reduce((sum, cur) => sum + callback(cur), 0) / arr.length;
}
function hasCJK(str: string) {
return /\p{Unified_Ideograph}/u.test(str);
}
function xnor(input1: boolean, input2: boolean) {
return (input1 && input2) || (!input1 && !input2);
}
function debugDoc(data: RecognizerData) {
function parsePage(page: RecognizerPage) {
return page[2][0][0][0][4].map((line) => {
return line[0].map((word) => {
return {
xMin: word[0],
yMin: word[1],
xMax: word[2],
yMax: word[3],
fontSize: word[4],
spaceAfter: Boolean(word[5]),
baseline: word[6],
rotation: Boolean(word[7]),
underlined: Boolean(word[8]),
bold: Boolean(word[9]),
italic: Boolean(word[10]),
colorIndex: word[11],
fontIndex: word[12],
text: word[13],
};
});
});
}
const pages = data.pages.map(parsePage);
return pages.map((page) =>
page.map((line) => ({
fontSize: average(line, (word) => word.fontSize).toFixed(2),
text: line
.map((word) => `${word.text}${word.spaceAfter ? " " : ""}`)
.join(""),
baseLine: average(line, (word) => word.baseline),
})),
);
}
const breakMarks = [
// 地址
/关键词[::]/,
/^(?((\d*\.)?\s*[\p{Unified_Ideograph},;\s]+\d{6}\b)+)?/u,
/^[[【〔[]]?收稿日期/,
/^[[【〔[]]?\**通(信|讯)作者/,
/(原|独)创性声明$/,
/使用授权(书|声明)$/,
/^目录$/,
/^(中文)?摘要$/,
];
const keyPatterns: {
[type in DocType]: {
[className: string]: RegExp[];
};
} = {
article: {},
thesis: {
"before-title": [
/(?\p{Unified_Ideograph}*((硕|博)士)?(研究生)?.*([学宇字]位|毕业)论文)?$/u,
/^(?\p{Unified_Ideograph}*(([硕博][士±])?(专业|[学宇字]术)|([硕博][士|±])(专业|[学宇字]术)?)[学宇字]位)?$/u,
/^(?\p{Unified_Ideograph}*博士后研究工作报告)?$/u,
// 陕西师范大学《气候变化和人类活动对祁连山草地演变影响程度的研究》
/^((专业|[学宇字]术)型)$/,
// 广西师范学院《农户耕地撂荒影响因素研究》
/^论文题目([((]中英文[))])$/,
],
},
book: {},
};
function patternsInType(type: DocType): RegExp[] {
return Object.values(keyPatterns[type]).flat();
}
const docTypePatterns: {
[type in DocType]: RegExp[];
} = {
article: [],
thesis: [
...patternsInType("thesis"),
/(([学宇字]|院)校|单位)代码/,
/保?密等?级/,
/[学宇字]号/,
/(研究生|([学宇字]位)?申请人)(姓名)?/,
/所在([学宇字]院|单位)|培养单位/,
/(指导教师|导师)(姓名)?/,
/(专业|[学宇字]科)(领域)?(名称)?/,
/(论文)?(答辩|提交|完成)(日期|时间)/,
/答辩委员会/,
],
book: [],
};
const letterShapeMap = {
/* Uppercase letters */
A: "A",
B: "B",
C: "C",
D: "D",
E: "E",
F: "F",
G: "G",
H: "H",
I: "I",
J: "J",
K: "K",
L: "L",
M: "M",
N: "N",
O: "O",
P: "P",
Q: "Q",
R: "R",
S: "S",
T: "T",
U: "U",
V: "V",
W: "W",
X: "X",
Y: "Y",
Z: "Z",
/* Lowercase letters */
a: "a",
b: "b",
c: "c",
d: "d",
e: "e",
f: "f",
g: "g",
h: "h",
i: "i",
j: "j",
k: "k",
l: "l",
m: "m",
n: "n",
o: "o",
p: "p",
q: "q",
r: "r",
s: "s",
t: "t",
u: "u",
v: "v",
w: "w",
x: "x",
y: "y",
z: "z",
/* Arabic numerals */
"0": "0",
"1": "1",
"2": "2",
"3": "3",
"4": "4",
"5": "5",
"6": "6",
"7": "7",
"8": "8",
"9": "9",
};
const footnoteMarkers = "**∗●Δ①②③④⑤⑥⑦⑧⑨➀➁➃➄➅➆➇➈";
function normalizeText(str: string) {
str = Zotero.Utilities.trimInternal(str);
for (const fullChar in letterShapeMap) {
str = str.replace(
new RegExp(fullChar, "g"),
letterShapeMap[fullChar as keyof typeof letterShapeMap],
);
}
return (
str
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F\p{Private_Use}]/gu, "")
.replace(/\s?([\p{Unified_Ideograph}—-])\s?/gu, "$1")
.trim()
);
}
export { getPDFTitle };
================================================
FILE: src/utils/prefs.ts
================================================
import { config } from "../../package.json";
export type PluginPrefsMap = _ZoteroTypes.Prefs["PluginPrefsMap"];
const PREFS_PREFIX = config.prefsPrefix;
/**
* Get preference value.
* Wrapper of `Zotero.Prefs.get`.
* @param key
*/
export function getPref(key: K) {
return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K];
}
/**
* Set preference value.
* Wrapper of `Zotero.Prefs.set`.
* @param key
* @param value
*/
export function setPref(
key: K,
value: PluginPrefsMap[K],
) {
return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true);
}
/**
* Clear preference value.
* Wrapper of `Zotero.Prefs.clear`.
* @param key
*/
export function clearPref(key: string) {
return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true);
}
================================================
FILE: src/utils/task.ts
================================================
import { metaSearch, metaTranslate } from "../modules/services";
import { getString } from "./locale";
import { attachmentSearch, importAttachment } from "../modules/attachments";
import { version } from "../../package.json";
// 创建 Deferred 的工厂函数
function createDeferred(): DeferredResult {
let resolve!: (value: T) => void;
let reject!: (reason?: any) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export class ScraperTask implements ScraperTask {
public id: string;
public item: Zotero.Item;
public type: ScraperTaskType;
public message?: string;
public silent?: false;
public deferred?: DeferredResult;
public resultIndex?: 0;
private _status: TaskStatus;
private _searchResults: ScrapeSearchResult[] = [];
constructor(item: Zotero.Item, type: ScraperTaskType, silent?: false) {
this.id = Zotero.Utilities.Internal.md5(item.id.toString());
this.item = item;
this.type = type;
this.silent = false;
this._status = "waiting";
}
// 添加消息的方法(不需要通过代理)
addMsg(message: string) {
if (this.message) {
this.message = this.message + "\n" + message;
} else {
this.message = message;
}
}
// 使用 setter 处理属性变更
set status(newStatus: TaskStatus) {
const oldStatus = this._status;
// if (oldStatus === newStatus) return;
this._status = newStatus;
ztoolkit.log(
`task ${this.id} changes "status" from "${oldStatus}" to "${newStatus}"`,
);
addon.data.progress.updateTaskStatus(this, newStatus);
if (newStatus === "multiple_results") {
this.deferred = createDeferred();
}
}
get status(): TaskStatus {
return this._status;
}
set searchResults(results: ScrapeSearchResult[]) {
this._searchResults = results;
ztoolkit.log("searchResult changed");
if (results && results.length > 1) {
addon.data.progress.updateTaskSearchResult(this, results);
}
}
get searchResults() {
return this._searchResults;
}
}
export class AttachmentTask implements AttachmentTask {
public id: string;
public item: Zotero.Item;
public type: AttachmentTaskType;
public message?: string;
public silent?: false;
public deferred?: DeferredResult;
public resultIndex?: 0;
private _status: TaskStatus;
private _searchResults: AttachmentSearchResult[] = [];
constructor(item: Zotero.Item, type: AttachmentTaskType, silent?: false) {
this.id = Zotero.Utilities.Internal.md5(item.id.toString());
this.item = item;
this.type = type;
this.silent = false;
this._status = "waiting";
}
// 添加消息的方法(不需要通过代理)
addMsg(message: string) {
if (this.message) {
this.message = this.message + "\n" + message;
} else {
this.message = message;
}
}
// 使用 setter 处理属性变更
set status(newStatus: TaskStatus) {
const oldStatus = this._status;
// if (oldStatus === newStatus) return;
this._status = newStatus;
ztoolkit.log(
`task ${this.id} changes "status" from "${oldStatus}" to "${newStatus}"`,
);
addon.data.progress.updateTaskStatus(this, newStatus);
if (newStatus === "multiple_results") {
this.deferred = createDeferred();
}
}
get status(): TaskStatus {
return this._status;
}
set searchResults(results: AttachmentSearchResult[]) {
this._searchResults = results;
ztoolkit.log("searchResult changed");
if (results && results.length > 1) {
addon.data.progress.updateTaskSearchResult(this, results);
}
}
get searchResults() {
return this._searchResults;
}
}
export class TaskRunner {
public runningTask: AttachmentTask | ScraperTask | null = null;
public tasks: (AttachmentTask | ScraperTask)[] = [];
getTaskType(
task: AttachmentTask | ScraperTask | string,
): "metaScraper" | "attachmentScraper" {
let taskType: string;
if (typeof task === "string") {
taskType = task;
} else {
taskType = task.type;
}
if (taskType == "attachment" || taskType == "snapshot") {
return "metaScraper";
} else if (taskType == "local" || taskType == "remote") {
return "attachmentScraper";
} else {
throw new Error(`Unknown task type: ${taskType}`);
}
}
createTask(
item: Zotero.Item,
type: ScraperTaskType | AttachmentTaskType,
silent?: false,
): ScraperTask | AttachmentTask {
const taskType = this.getTaskType(type);
let task: ScraperTask | AttachmentTask;
if (taskType === "attachmentScraper") {
task = new AttachmentTask(item, type as AttachmentTaskType, silent);
} else if (taskType === "metaScraper") {
task = new ScraperTask(item, type as ScraperTaskType, silent);
} else {
throw new Error(`Unknown task type: ${type}`);
}
// Set the default index for silent tasks
// If the task is silent, set the resultIndex to 0
if (silent) {
task.resultIndex = 0;
}
return task;
}
async addTask(
task: AttachmentTask | ScraperTask,
): Promise {
if (this.getTaskById(task.id)) {
ztoolkit.log(`Task with ID ${task.id} already exists.`);
if (addon.data.progress.progressWindow) {
addon.data.progress.progressWindow.alert(
getString("task-already-exists", {
args: { title: task.item.getField("title") },
}),
);
}
return;
}
this.tasks.push(task);
await addon.data.progress.addTaskToProgressWindow(task);
ztoolkit.log(`Task with ID ${task.id} added.`);
await this.runTask(task);
return task.id;
}
async createAndAddTask(
item: Zotero.Item,
type: ScraperTaskType | AttachmentTaskType,
silent?: false,
): Promise {
const task = this.createTask(item, type, silent);
task.addMsg(getString("task-msg-header"));
task.addMsg(`Zotero version: ${Zotero.version}`);
task.addMsg(`Addon version: ${version}`);
await this.addTask(task);
return task.id;
}
getTaskById(id: string): Task | undefined {
return this.tasks.find((task) => task.id === id);
}
async runTask(task: AttachmentTask | ScraperTask): Promise {
this.runningTask = task;
if (this.getTaskType(task) === "attachmentScraper") {
this.runAttachmentTask(task as AttachmentTask);
} else {
this.runScrapeTask(task as ScraperTask);
}
this.runningTask = null;
}
async runScrapeTask(task: ScraperTask): Promise {
// Implement the logic to run the scrape task
ztoolkit.log(`Running scrape task with ID: ${task.id}`);
try {
await metaSearch(task);
} catch (e) {
ztoolkit.log(`Error in metaSearch: ${e}`);
task.addMsg(`Error in metaSearch: ${e}`);
task.status = "fail";
return;
}
// Wait for user select result.
if (task.status === "multiple_results") {
task.resultIndex = await task.deferred?.promise;
}
if (task.status != "fail") {
await metaTranslate(task);
}
}
async runAttachmentTask(task: AttachmentTask): Promise {
await attachmentSearch(task);
// Wait for user select result.
if (task.status === "multiple_results") {
task.resultIndex = await task.deferred?.promise;
}
if (task.status != "fail") {
await importAttachment(task);
}
}
resumeTask(taskID: string, resultIndex: number): void {
const task = this.getTaskById(taskID);
if (task?.deferred) {
task.deferred.resolve(resultIndex);
}
}
}
================================================
FILE: src/utils/wait.ts
================================================
/**
* Wait until the condition is `true` or timeout.
* The callback is triggered if condition returns `true`.
* @param condition
* @param callback
* @param interval
* @param timeout
*/
export function waitUntil(
condition: () => boolean,
callback: () => void,
interval = 100,
timeout = 10000,
) {
const start = Date.now();
const intervalId = ztoolkit.getGlobal("setInterval")(() => {
if (condition()) {
ztoolkit.getGlobal("clearInterval")(intervalId);
callback();
} else if (Date.now() - start > timeout) {
ztoolkit.getGlobal("clearInterval")(intervalId);
}
}, interval);
}
/**
* Wait async until the condition is `true` or timeout.
* @param condition
* @param interval
* @param timeout
*/
export function waitUtilAsync(
condition: () => boolean,
interval = 100,
timeout = 10000,
) {
return new Promise((resolve, reject) => {
const start = Date.now();
const intervalId = ztoolkit.getGlobal("setInterval")(() => {
if (condition()) {
ztoolkit.getGlobal("clearInterval")(intervalId);
resolve();
} else if (Date.now() - start > timeout) {
ztoolkit.getGlobal("clearInterval")(intervalId);
reject();
}
}, interval);
});
}
================================================
FILE: src/utils/window.ts
================================================
import { config } from "../../package.json";
import { waitUtilAsync } from "./wait";
/**
* Check if the window is alive.
* Useful to prevent opening duplicate windows.
* @param win
*/
export function isWindowAlive(win?: Window) {
return win && !Components.utils.isDeadWrapper(win) && !win.closed;
}
/**
* Ensures that a given promise resolves within a specified timeout.
* If the promise does not resolve within the timeout, it rejects with an error.
* @param promise - The promise to wait for.
* @param timeout - The maximum time to wait in milliseconds.
* @param message - The error message to reject with if the promise does not resolve within the timeout.
*/
export async function waitNoMoreThan(
promise: Promise,
timeout: number = 3000,
message: string = "Timeout",
) {
let resolved = false;
return Promise.any([
promise.then((result) => {
resolved = true;
return result;
}),
// @ts-ignore - Promise delay is not typed.
Zotero.Promise.delay(timeout).then(() => {
if (resolved) return;
throw new Error(message);
}),
]);
}
export function findWindow(type: string) {
const enumerator = Services.wm.getEnumerator(type);
if (enumerator.hasMoreElements()) {
// In this case, getNext will always return a window
const win = enumerator.getNext() as Window;
ztoolkit.log(`found window by type: ${type}, ${win.location.href}`);
return win;
}
ztoolkit.log(`not found window by type: ${type}`);
return null;
}
export function observeWindowLoad(
uri: string,
callback: (win: Window) => unknown,
) {
// After the window opens, wait for it to load
const loadObserver = function (event: Event) {
event.originalTarget?.removeEventListener("load", loadObserver, false);
const href = (event.target as Window)?.location.href;
ztoolkit.log(`window loaded: ${href}`);
if (href != uri) {
return;
}
const win = event.target?.ownerGlobal;
// Give window code time to run on load
win?.setTimeout(function () {
callback(win);
});
};
// Ensure that the window is opened before listening for load
const winObserver = {
observe: function (subject: Window, topic: string, data: any) {
if (topic != "domwindowopened") return;
subject.addEventListener("load", loadObserver, false);
},
} as nsIObserver;
Services.ww.registerNotification(winObserver);
// Unregister notifier when addon is disabled
Zotero.Plugins.addObserver({
shutdown: ({ id }) => {
if (id === config.addonID)
Services.ww.unregisterNotification(winObserver);
},
});
}
export async function waitElmLoaded(
doc: Document,
selector: string,
timeout = 10000,
): Promise {
return new Promise((resolve, reject) => {
waitUtilAsync(() => !!doc.querySelector(selector), 100, timeout)
.then(() => {
ztoolkit.log(`element ${selector} in ${doc.location.href} loaded`);
resolve(true);
})
.catch(() => {
ztoolkit.log(
`timeout waiting for element ${selector} in ${doc.location.href}`,
);
reject(false);
});
});
}
================================================
FILE: src/utils/ztoolkit.ts
================================================
import { ZoteroToolkit } from "zotero-plugin-toolkit";
import { config } from "../../package.json";
export { createZToolkit };
function createZToolkit() {
const _ztoolkit = new ZoteroToolkit();
/**
* Alternatively, import toolkit modules you use to minify the plugin size.
* You can add the modules under the `MyToolkit` class below and uncomment the following line.
*/
// const _ztoolkit = new MyToolkit();
initZToolkit(_ztoolkit);
return _ztoolkit;
}
function initZToolkit(_ztoolkit: ReturnType) {
const env = __env__;
_ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`;
_ztoolkit.basicOptions.log.disableConsole = false;
_ztoolkit.UI.basicOptions.ui.enableElementJSONLog = true;
_ztoolkit.UI.basicOptions.ui.enableElementDOMLog = true;
_ztoolkit.basicOptions.debug.disableDebugBridgePassword =
__env__ === "development";
_ztoolkit.basicOptions.api.pluginID = config.addonID;
_ztoolkit.ProgressWindow.setIconURI(
"default",
`chrome://${config.addonRef}/content/icons/icon.png`,
);
}
import { BasicTool, unregister } from "zotero-plugin-toolkit";
import { UITool } from "zotero-plugin-toolkit";
class MyToolkit extends BasicTool {
UI: UITool;
constructor() {
super();
this.UI = new UITool(this);
}
unregisterAll() {
unregister(this);
}
}
================================================
FILE: test/CNKI_translator_test.js
================================================
urls = [
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxXklMd97G3EDrMY35-fvcPWOGNHjK8hkbbqADLh5NGc0AmBzjI4D8ZwjzI0VHODsFQlS8sdwe2eU_tJN9s3hzTpF8GC2jjom6r22HYP5NTbScHRtT5YFMOmmBgOTPkcgh2Bsw--3eXUh2HpUIUCy4q97DM744ETBHcmNUo8g9ZvnNyVq_LgaRN&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchowBkjKe5MjlmHuD-RasNVJ8dt-b6UD3KtSDnt1n7QxJkch8TZaKbJ8-7MQj0xUrYGr09gE16oyAIvXEr-CiiCdkjjgSqFVq36YzC43jvtJ2f8hTOpY1PGy1XXfcCGLSIRM1wzVCGndk4adUcZQZlQHK2d03J4NCfegvuPcn9U-_NhqwCcaqf5w6&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchowjmw6mw3uKbpWLZ2thd4Ikj9aGW2c6LgczKWCuX2sbJIEMj6bB-0Myb9sYR96tSlb8Gk0R2Z5YBIcrMYuwuydhjwJOEbFIivRXFXmcPpCYR-7eh9QpC0Giq2tNOlOdx3rz-Z-fSV0yg_0xomFu3lvR2KqJ3Zf28JSRHc4XLMIunw9eaiJQVXkZ&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozhh8Fgt64WtzqgcMwtTHrBrYlh7Q4yySg7hnJ_gZrfncEA9PcKga4FKUf8K3eYz08N-kJkGU6a490pkki0kvUVkunG1MGAhnigUdsqHsG2wsWHE_qELcwKOaJxO6eY8DTnp1B33RIvWXn7MIYquE-yHHXjmoQOk2vu5RZvFrEQBYLhzKS-EGmA&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoza91b55tjwPPpLVRsheZOKpFgcAdm6LDyIObh40SwtF5bPj3e9eAkU_c4S8MDmOIWVqRv5OJ6eq-KGt0sSbL9666NHWAvybhtwrlr-ULB8eGMFZS_h01YjBrmxfsdYY0cXK4SYnSghWoce8plJZitosysJn497tMNlbBu19zoyZ5-G5ms_xGKc&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozs-rV1BnuxrpzoDrIrhHINoJIdxyeKIPB5uWktgKly2H6ZJQQUSjRUVoPUCHXoyZLBT2eDcH5MrDol9zfNqvscmrbJfOFiKe2bkCEET1Voc00whH0Bu4xrNDK0j5RUMqKj2JH-EELIoBDNoj1nmj6lcvxK69GmV48jw6V6GBOlfa5grOY_WfBB&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozAYll_0WbZyNsWw69XWM7MujYLUcTpPk_wHBzcDHl6z8xIFKgbBP9GUZ9aGf3xAFtR4ludtNumAxX85oiU2rxkqQcMorrBsa9mvJm0YqwmGSbDSJixg-Sfl3jFOFbqE9hxs1wOIhddFbgjxBRi6P-nt1qdcyak53AgrKhuQ7zmthllMkRJhI4G&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxEbdsGcNHq4lrTk8lk2vE82akoFjaBTi997kL-xCmHHFG8ddNmO7SYE8ibGqs4um70WtUldjIAcPyXe_UQ9_FZ7CrfrL1LkRsBTlBS8dg1um9MAQ2PUKnlEYe7jGB4s2dVwVpuRiIYVChSfZwqfit-ESOlDTf3yUU1IGYJXEZjmjBuDiN1yYTE&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozMxohk4EHQgPvZYZSpp2pf1Clf4BOwXKJW-wtnhxwYhRRBeURaHD2zPZ0ddjazGcg8BfOp4LAD4h9Cpn1ayUP2VlWH_6RkdOx6hI_uuWiYNJFz-yugVFEJvifSPBEHKkHRon3KEsfqvr-R83eYznYYIsa-pzPO4Jum2xGpudDoD5HuwW0UjJn6&uniplatform=NZKPT&language=CHS",
"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoy5-_CNHo0wZ1paiWwks_fDQzOTRX44Y6bF7RG-Fpwv4BP68IIvRyWLrA5ae8uTjpDS7CxdemHyUTxIOoha7kIy0vBR3XEZvBSztPhhfGdWk8RxOlqNUqh4v3n-eBRQq0c_YhcPyvuSjfz5Zga5iA4ijhKzLMwUrRsgc0Ad7kbLc0dtOpz9nz3V&uniplatform=NZKPT&language=CHS",
];
async function getDoc(url) {
const xhr = await Zotero.HTTP.request("GET", url, {
responseType: "document",
});
let doc = xhr.response;
if (doc && !doc.location) {
doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL);
}
return doc;
}
async function translate(doc) {
const translate = new Zotero.Translate.Web();
// CNKI
translate.setTranslator("5c95b67b-41c5-4f55-b71a-48d5d7183063");
translate.setDocument(doc);
const items = await translate.translate({
libraryID: false, // 不保存
saveAttachments: false,
});
const item = items[0];
return item;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function test(urls) {
for (let i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
console.log("#####" + i);
for (let url of urls) {
await delay(3000);
let d = await getDoc(url);
let item = await translate(d);
console.log(item.key, item.title, item.getField("publicationTitle"));
}
}
}
================================================
FILE: test/expert_china.json
================================================
{
"boolSearch": "true",
"QueryJson": {
"Platform": "",
"Resource": "CROSSDB",
"Classid": "WD0FTY92",
"Products": "",
"QNode": {
"QGroup": [
{
"Key": "Subject",
"Title": "",
"Logic": 0,
"Items": [
{
"Key": "Expert",
"Title": "",
"Logic": 0,
"Field": "EXPERT",
"Operator": 0,
"Value": "TI+%+'黄瓜共表达'+and+AU='林行众'",
"Value2": ""
}
],
"ChildItems": []
},
{
"Key": "ControlGroup",
"Title": "",
"Logic": 0,
"Items": [],
"ChildItems": []
}
]
},
"ExScope": "1",
"SearchType": 4,
"Rlang": "CHINESE",
"KuaKuCode": "YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV",
"SearchFrom": 1
},
"pageNum": "1",
"pageSize": "20",
"sortField": "",
"sortType": "",
"dstyle": "listmode",
"productStr": "YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,",
"aside": "(TI+%+'黄瓜共表达'+and+AU='林行众')",
"searchFrom": "资源范围:总库;++中英文扩展;++时间范围:更新时间:不限;++",
"CurPage": "1"
}
================================================
FILE: test/expert_oversea.json
================================================
{
"IsSearch": "true",
"QueryJson": {
"Platform": "",
"DBCode": "CFLS",
"KuaKuCode": "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN",
"QNode": {
"QGroup": [
{
"Key": "Subject",
"Title": "",
"Logic": 4,
"Items": [
{
"Key": "Expert",
"Title": "",
"Logic": 0,
"Name": "",
"Operate": "",
"Value": "TI+%+'黄瓜共表达'+and+AU='林行众'",
"ExtendType": 12,
"ExtendValue": "中英文对照",
"Value2": "",
"BlurType": ""
}
],
"ChildItems": []
},
{
"Key": "ControlGroup",
"Title": "",
"Logic": 1,
"Items": [],
"ChildItems": []
}
]
},
"ExScope": 1,
"CodeLang": ""
},
"PageName": "AdvSearch",
"DBCode": "CFLS",
"KuaKuCodes": "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN",
"CurPage": "1",
"RecordsCntPerPage": "20",
"CurDisplayMode": "listmode",
"CurrSortField": "",
"CurrSortFieldType": "desc",
"IsSentenceSearch": "false",
"Subject": ""
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"experimentalDecorators": true,
"module": "commonjs",
"target": "ES2016",
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src", "typings", "node_modules/zotero-types"],
"exclude": ["build", "addon"]
}
================================================
FILE: typings/attachment.d.ts
================================================
interface AttachmentService {
searchAttachments(
task: AttachmentTask,
): Promise;
importAttachment(task: AttachmentTask): Promise;
}
type AttachmentSearchResult = {
title: string;
filename: string;
score?: number;
url: string;
source?: string;
};
interface AttachmentTask extends Task {
type: AttachmentTaskType;
searchResults?: AttachmentSearchResult[];
}
================================================
FILE: typings/global.d.ts
================================================
declare const _globalThis: {
[key: string]: any;
Zotero: _ZoteroTypes.Zotero;
ztoolkit: ZToolkit;
addon: typeof addon;
};
declare type ZToolkit = ReturnType<
typeof import("../src/utils/ztoolkit").createZToolkit
>;
declare const ztoolkit: ZToolkit;
declare const rootURI: string;
declare const addon: import("../src/addon").default;
declare const __env__: "production" | "development";
================================================
FILE: typings/i10n.d.ts
================================================
// Generated by zotero-plugin-scaffold
/* prettier-ignore */
/* eslint-disable */
// @ts-nocheck
export type FluentMessageId =
| 'CNKIcitation'
| 'action-after-import'
| 'action-after-import-desc'
| 'attachment-folder-desc'
| 'backup-label'
| 'bookmark'
| 'bookmark-add'
| 'bookmark-delete'
| 'citation'
| 'confirm-close'
| 'delete-label'
| 'get-Chinese-styles'
| 'github-link'
| 'help-menu-addons'
| 'help-menu-chinese'
| 'help-menu-csl'
| 'help-menu-translator'
| 'help-menu-wiki'
| 'how-to-update-translators'
| 'import-attachments-success'
| 'importing-attachments-is-running'
| 'info-best-speed-source-failed'
| 'info-best-speed-source-updated'
| 'info-translators-cn-updaing'
| 'item-section-example1-head-text'
| 'item-section-example1-sidenav-tooltip'
| 'item-section-example2-button-tooltip'
| 'item-section-example2-head-text'
| 'item-section-example2-sidenav-tooltip'
| 'label-auto-split-name'
| 'label-auto-update-translators'
| 'label-autoupdate-metadata'
| 'label-best-speed'
| 'label-choose-folder'
| 'label-choose-namepattern'
| 'label-choose-source'
| 'label-disableZoteroOutline'
| 'label-enableBookmark'
| 'label-install-wps-plugin-click'
| 'label-isMainlandChina'
| 'label-language'
| 'label-metadata-source'
| 'label-metadata-source-cnki'
| 'label-metadata-source-cvip'
| 'label-namepattern'
| 'label-namepattern-auto'
| 'label-namepattern-custom'
| 'label-namepattern-info'
| 'label-namepattern-t'
| 'label-namepattern-tg'
| 'label-pdf-match-folder'
| 'label-rename'
| 'label-split-en-name'
| 'label-tools-info-1'
| 'label-tools-info-2'
| 'label-tools-linter'
| 'label-translator-source'
| 'label-translators-detail'
| 'label-translators-detail-click'
| 'label-translators-force-update'
| 'label-wps'
| 'label-wps-help'
| 'label-zotero-chinese'
| 'menu-metadata'
| 'menu-tools'
| 'menuitem-find-attachment'
| 'menuitem-import-attachments'
| 'menuitem-mergeName'
| 'menuitem-retrieveMetadata'
| 'menuitem-retrieveMetadataForBook'
| 'menuitem-splitName'
| 'menuitem-updateCNKICite'
| 'namepattern-desc'
| 'no-attachments-found'
| 'no-chinese-item-for-citation'
| 'no-item-need-attachment'
| 'nothing-label'
| 'outline'
| 'outline-add'
| 'outline-collapse-all'
| 'outline-delete'
| 'outline-delete-confirm'
| 'outline-desc'
| 'outline-edit-placeholder'
| 'outline-empty-prompt'
| 'outline-expand-all'
| 'outline-save-to-pdf'
| 'plugin-name'
| 'pref-enable'
| 'pref-group-about'
| 'pref-group-attachment'
| 'pref-group-bookmark'
| 'pref-group-metadata'
| 'pref-group-tools'
| 'pref-group-translators'
| 'pref-group-wps'
| 'pref-help'
| 'prefs-table-detail'
| 'prefs-table-title'
| 'report-translator-bug'
| 'request-new-translator'
| 'result-score'
| 'result-source'
| 'result-title'
| 'search-box'
| 'select-download-folder'
| 'tabpanel-lib-tab-label'
| 'tabpanel-reader-tab-label'
| 'task-already-exists'
| 'task-list'
| 'task-msg-header'
| 'th-filename'
| 'th-label'
| 'th-local-update-time'
| 'th-remote-update-time'
| 'title'
| 'translatorSource-desc'
| 'translators-dashboard'
| 'update-failed'
| 'update-skipped'
| 'update-successfully'
| 'update-translators-complete'
| 'update-translators-start';
================================================
FILE: typings/myzotero.d.ts
================================================
declare namespace Zotero {
/**
* Cookie 对象的内部存储结构
*/
interface CookieData {
/** Cookie 的值 */
value: string;
/** 是否为 secure cookie */
secure: boolean;
/** 是否为 host-only cookie */
hostOnly: boolean;
}
/**
* Cookie 存储的内部结构
* 格式: { ".host": { "/path": { "cookieName": CookieData } } }
*/
interface CookieStorage {
[host: string]: {
[path: string]: {
[name: string]: CookieData;
};
};
}
/**
* getCookiesForURI 返回的简单 cookie 对象
* 格式: { "cookieName": "cookieValue" }
*/
interface CookieDict {
[name: string]: string;
}
/**
* Manage cookies in a sandboxed fashion
*/
class CookieSandbox {
/**
* Internal cookie storage
* @internal
*/
_cookies: CookieStorage;
/**
* User agent string to use for sandboxed requests
*/
userAgent?: string;
/**
* Create a new CookieSandbox instance
*
* @param browser - Hidden browser object
* @param uri - URI of page to manage cookies for (cookies for domains that are not subdomains of this URI are ignored)
* @param cookieData - Cookies with which to initiate the sandbox
* @param userAgent - User agent to use for sandboxed requests
*
* @example
* ```typescript
* // Create an empty sandbox
* const sandbox = new Zotero.CookieSandbox();
*
* // Create with initial cookies
* const sandbox = new Zotero.CookieSandbox(
* null,
* "https://example.com",
* "sessionId=abc123; userId=456"
* );
* ```
*/
constructor(
browser?: any,
uri?: string | Components.interfaces.nsIURI,
cookieData?: string,
userAgent?: string,
);
/**
* Clone this CookieSandbox
*
* @returns A deep copy of this CookieSandbox
*/
clone(): CookieSandbox;
/**
* Add cookies to this CookieSandbox based on a cookie header
*
* @param cookieString - Cookie header string (can contain multiple cookies separated by newlines)
* @param uri - URI of the header origin. Used to verify same origin. If omitted, validation is not performed
*/
addCookiesFromHeader(
cookieString: string,
uri?: Components.interfaces.nsIURI,
): void;
/**
* Attach CookieSandbox to a specific browser
*
* @param browser - Browser element to attach to
*/
attachToBrowser(browser: any): void;
/**
* Attach CookieSandbox to a specific XMLHttpRequest
*
* @param ir - Interface requestor
*/
attachToInterfaceRequestor(
ir: Components.interfaces.nsIInterfaceRequestor | any,
): void;
/**
* Set a cookie for a specified host
*
* @param cookiePair - A single cookie pair in the form "key=value"
* @param host - Host to bind the cookie to
* @param path - Cookie path (defaults to "/")
* @param secure - Whether the cookie has the secure attribute set
* @param hostOnly - Whether the cookie is a host-only cookie
*/
setCookie(
cookiePair: string,
host: string,
path?: string,
secure?: boolean,
hostOnly?: boolean,
): void;
/**
* Returns a list of cookies that should be sent to the given URI
*
* @param uri - The URI to get cookies for (must be nsIURI object, not string)
* @returns Object containing cookie name-value pairs, or null if no cookies found
*/
getCookiesForURI(uri: Components.interfaces.nsIURI): CookieDict | null;
/**
* Internal method to get cookies for a specific path
* @internal
*/
_getCookiesForPath(
cookies: CookieDict,
cookiePaths: any,
pathParts: string[],
secure: boolean,
isHost: boolean,
): boolean;
}
namespace CookieSandbox {
/**
* Initialize the CookieSandbox observer
*/
function init(): void;
/**
* Normalize the host string: lower-case, remove leading period, some more cleanup
*
* @param host - Host string to normalize
* @returns Normalized host string
*/
function normalizeHost(host: string): string;
/**
* Normalize the path string
*
* @param path - Path string to normalize
* @returns Normalized path string
*/
function normalizePath(path: string): string;
/**
* Generate a semicolon-separated string of cookie values from a cookie object
*
* @param cookies - Object containing key-value cookie pairs
* @returns Cookie string in format "name1=value1; name2=value2"
*/
function generateCookieString(cookies: CookieDict): string;
/**
* Observer for managing cookies across different contexts
*/
namespace Observer {
/** WeakMap of browsers tracked by CookieSandbox */
const trackedBrowsers: WeakMap;
/** WeakMap of interface requestors tracked by CookieSandbox */
const trackedInterfaceRequestors: WeakMap;
/**
* Register the cookie observer
*/
function register(): void;
/**
* Observe HTTP events to manage cookies
*
* @param channel - HTTP channel
* @param topic - Observer topic
*/
function observe(channel: any, topic: string): void;
}
}
}
================================================
FILE: typings/notifier.d.ts
================================================
================================================
FILE: typings/outline.d.ts
================================================
type OutlineNode = {
level: number;
title: string;
page: number;
x: number;
y: number;
children?: OutlineNode[];
collapsed?: boolean;
ref?: PDFRef;
};
type OutlineInfo = {
info: Record & {
baseFontSize?: number; // Base font size for level-1, default 12
};
outline: OutlineNode[];
};
// Reference of PDF object
// type PdfRef = {
// num: number;
// gen: number;
// tag?: string; // 可能是 "Page" 或 "Outline"
// };
type PdfZoomMode = {
name: string; // 缩放模式名称,例如 "Fit", "XYZ", "FitH", "FitV"
args?: (number | null)[];
};
type PdfDest = { dest: [PDFRef, PdfZoomMode] };
type PdfPosition = {
position: { pageIndex: number; rects: [number, number, number, number][] };
};
type PdfOutlineNode = {
title: string;
items: PdfOutlineNode[];
location: PdfDest | PdfPosition; // 没有遇到 PdfDest 的情况
};
// 书签相关类型定义
type BookmarkNode = {
id: string; // 唯一标识符
title: string;
page: number;
x: number;
y: number;
order: number; // 用于排序,书签没有层级关系
createdAt: number; // 创建时间戳
color: string; // 书签颜色
};
type BookmarkInfo = {
info: {
itemID: number;
schema: number;
jasminumVersion: string;
baseFontSize?: number; // outline 12, bookmark 13
};
bookmarks: BookmarkNode[];
};
================================================
FILE: typings/pdfParser.d.ts
================================================
type RecognizerData = {
metadata: {
[key: string]: string;
};
totalPages: number;
pages: RecognizerPage[];
};
type PdfData = {
metadata: {
[key: string]: string;
};
totalPages: number;
pages: PdfPage[];
};
type RecognizerPage = {
// pageWidth
0: number;
// pageHeight
1: number;
2: [[[[0, 0, 0, 0, RecognizerLine[]]]]];
};
type PdfPage = {
width: number;
height: number;
text: string;
classList: string[];
paragraphs: PdfParagraph[];
};
type PdfParagraph = {
fontSize: string;
text: string;
classList: string[];
lines: PdfLine[];
};
type RecognizerLine = [RecognizerWord[]];
type PdfLine = {
xMin: number;
yMin: number;
xMax: number;
yMax: number;
fontSize: string;
baseline: number;
text: string;
words: PdfWord[];
};
type RecognizerWord = [
// 0: xMin
number,
// 1: yMin
number,
// 2: xMax
number,
// 3: yMax
number,
// 4: fontSize
number,
// 5: spaceAfter
0 | 1,
// 6: baseline
number,
// 7: rotation
0,
// 8: underlined
0,
// 9: bold
0 | 1,
// 10: italic
0 | 1,
// 11: colorIndex
0,
// 12: fontIndex
number,
// 13: text
string,
];
type PdfWord = {
xMin: number;
yMin: number;
xMax: number;
yMax: number;
fontSize: number;
spaceAfter: boolean;
baseline: number;
rotation: boolean;
underlined: boolean;
bold: boolean;
italic: boolean;
colorIndex: number;
fontIndex: number;
text: string;
};
type DocType = "article" | "thesis" | "book";
================================================
FILE: typings/prefs.d.ts
================================================
// Generated by zotero-plugin-scaffold
/* prettier-ignore */
/* eslint-disable */
// @ts-nocheck
// prettier-ignore
declare namespace _ZoteroTypes {
interface Prefs {
PluginPrefsMap: {
"firstRun": boolean;
"translatorsMended": boolean;
"autoSplitName": boolean;
"splitEnName": boolean;
"language": string;
"autoUpdateMetadata": boolean;
"namePattern": string;
"namePatternCustom": string;
"metadataSource": string;
"isMainlandChina": boolean;
"cnkiAttachmentCookie": string;
"similarityThresholdForMetaData": string;
"pdfMatchFolder": string;
"actionAfterAttachmentImport": string;
"similarityThreshold": string;
"topMatchCount": number;
"autoUpdateTranslators": boolean;
"translatorUpdateTime": string;
"translatorSource": string;
"enableBookmark": boolean;
"newNodeAsChild": boolean;
"disableZoteroOutline": boolean;
};
}
}
================================================
FILE: typings/scrape.d.ts
================================================
interface ScrapeService {
search(searchOption: SearchOption): Promise;
searchSnapshot?(task: ScrapeTask): Promise;
translate(
searchResult: ScrapeSearchResult,
libraryID: number,
saveAttachments: false,
): Promise;
translateSnapshot?(task: ScrapeTask): Promise;
}
type SearchOption = {
author?: string;
title: string;
};
type ScrapeSearchResult = {
source: string;
title: string;
url: string;
[key: string]: string | number | null;
};
type TaskStatus =
| "waiting"
| "processing"
| "multiple_results"
| "success"
| "fail";
type ScraperTaskType = "attachment" | "snapshot";
type AttachmentTaskType = "local" | "remote";
interface Task {
id: string;
type: string;
item: Zotero.Item;
resultIndex?: 0;
status: TaskStatus;
silent?: boolean;
message?: string;
addMsg: (msg: string) => void;
deferred?: DeferredResult;
searchResults?: any[];
}
interface ScrapeTask extends Task {
type: ScraperTaskType;
searchResults?: ScrapeSearchResult[];
}
// 定义 Deferred 类型,用于等待用户输入,选择合适的结果索引
type DeferredResult = {
promise: Promise;
resolve: (value: T) => void;
reject: (reason?: any) => void;
};
================================================
FILE: typings/translators.d.ts
================================================
type LastUpdatedMap = {
[filename: string]: { label: string; lastUpdated: string };
};
type TableRow = {
filename: string;
label: string;
localUpdateTime: string;
remoteUpdateTime: string;
};
================================================
FILE: zotero-plugin.config.ts
================================================
import { defineConfig } from "zotero-plugin-scaffold";
import pkg from "./package.json";
export default defineConfig({
source: ["src", "addon"],
dist: "build",
name: pkg.config.addonName,
id: pkg.config.addonID,
xpiName: `${pkg.config.addonRef}_${pkg.version}`,
namespace: pkg.config.addonRef,
updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${
pkg.version.includes("-") ? "update-beta.json" : "update.json"
}`,
xpiDownloadLink:
"https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi",
build: {
assets: ["addon/**/*.*"],
define: {
...pkg.config,
author: pkg.author,
description: pkg.description,
homepage: pkg.homepage,
buildVersion: pkg.version,
buildTime: "{{buildTime}}",
},
prefs: {
prefix: pkg.config.prefsPrefix,
},
esbuildOptions: [
{
// entryPoints: ["src/index.ts"],
entryPoints: [
{ in: "src/index.ts", out: pkg.config.addonRef },
{
in: "src/modules/workers/index.ts",
out: `${pkg.config.addonRef}-worker`,
},
],
outdir: "build/addon/chrome/content/scripts",
define: {
__env__: `"${process.env.NODE_ENV}"`,
},
bundle: true,
target: "firefox115",
// outfile: `build/addon/chrome/content/scripts/${pkg.config.addonRef}.js`,
},
],
},
// If you need to see a more detailed log, uncomment the following line:
// logLevel: "trace",
});