diff --git a/addon/chrome/content/icons/matchAttachment.png b/addon/chrome/content/icons/matchAttachment.png new file mode 100644 index 0000000..d835113 Binary files /dev/null and b/addon/chrome/content/icons/matchAttachment.png differ diff --git a/addon/chrome/content/preferences.xhtml b/addon/chrome/content/preferences.xhtml index 82e9ccf..8647870 100644 --- a/addon/chrome/content/preferences.xhtml +++ b/addon/chrome/content/preferences.xhtml @@ -1,10 +1,7 @@ - + @@ -36,11 +37,7 @@ - + @@ -59,14 +56,8 @@ - + @@ -75,12 +66,8 @@ - + @@ -88,22 +75,16 @@ - + oncommand="Zotero_Preferences.navigateToPane('zotero-subpane-file-renaming')"> @@ -112,28 +93,13 @@ - - - + + + - + @@ -145,38 +111,22 @@ - - + \ No newline at end of file diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index a6745ca..754b9bc 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -1,6 +1,7 @@ attachment-manager = Attachment Manager attach-new-file = Attach New File rename-move-attachment = Rename and Move Attachment +match-attachment = Match Attachment rename-attachment = Rename Attachment move-attachment = Move Attachment open-using = Open Using diff --git a/addon/locale/en-US/preferences.ftl b/addon/locale/en-US/preferences.ftl index 3ac5b02..cd375ab 100644 --- a/addon/locale/en-US/preferences.ftl +++ b/addon/locale/en-US/preferences.ftl @@ -6,6 +6,14 @@ setting = Settings source-title = Source Path source-intro = <Attach New File> will retrieve the recently added file from this directory and attach it to the Zotero Item/Collection. +read-pdf-title = read title from PDF file: +readPDFtitle-never = + .label = Never +readPDFtitle-nonCJK = + .label = Except for CJK +readPDFtitle-always = + .label = Always + attach-title = Attach Type attach-intro = If using Zotero's official or WebDAV sync, choose <Stored Copy>; if using third-party sync such as Nutstore, OneDrive, etc., choose <Link> and properly configure the <Destination Path>. Files will be moved to the destination path and then imported into Zotero as a link attachment. attach-type-start = Attach file diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index e76a952..ff32de1 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -1,6 +1,7 @@ attachment-manager = 附件管理 attach-new-file = 附加新文件 rename-move-attachment = 重命名并移动附件 +match-attachment = 匹配附件 rename-attachment = 重命名附件 move-attachment = 移动附件 open-using = 打开方式 diff --git a/addon/locale/zh-CN/preferences.ftl b/addon/locale/zh-CN/preferences.ftl index 62aa5b2..d98b393 100644 --- a/addon/locale/zh-CN/preferences.ftl +++ b/addon/locale/zh-CN/preferences.ftl @@ -6,6 +6,14 @@ setting = 设置 source-title = 源路径 source-intro = <附加新文件> 会从该目录检索最近添加的文件并附加到 Zotero 条目/分类下。 +read-pdf-title = 从PDF读取标题: +readPDFtitle-never = + .label = 永不 +readPDFtitle-nonCJK = + .label = 仅限外文 +readPDFtitle-always = + .label = 始终 + attach-title = 附加类型 attach-intro = 若使用 Zotero 官方或 WebDAV 同步,请选择作为 <副本>;若使用第三方官方同步,如坚果云、OneDrive等,请选择作为 <链接> 并认真配置 <靶路径>,文件将会被移动到靶路径下后作为链接类型附件导入 Zotero。 attach-type-start = 文件作为 diff --git a/addon/prefs.js b/addon/prefs.js index 8497571..3ecb0b3 100644 --- a/addon/prefs.js +++ b/addon/prefs.js @@ -1,9 +1,10 @@ /* eslint-disable no-undef */ -pref("__prefsPrefix__.enable", true); -pref("__prefsPrefix__.attachType", "linking"); -pref("__prefsPrefix__.subfolderFormat", `{{collection}}`); -pref("__prefsPrefix__.sourceDir", ""); -pref("__prefsPrefix__.destDir", ""); -pref("__prefsPrefix__.autoMove", true); -pref("__prefsPrefix__.fileTypes", "pdf,doc,docx,txt,rtf,djvu,epub"); -pref("__prefsPrefix__.autoRemoveEmptyFolder", false); +pref("extensions.zotero.__addonRef__.enable", true); +pref("extensions.zotero.__addonRef__.attachType", "linking"); +pref("extensions.zotero.__addonRef__.subfolderFormat", `{{collection}}`); +pref("extensions.zotero.__addonRef__.sourceDir", ""); +pref("extensions.zotero.__addonRef__.readPdfTitle", "nonCJK"); +pref("extensions.zotero.__addonRef__.destDir", ""); +pref("extensions.zotero.__addonRef__.autoMove", true); +pref("extensions.zotero.__addonRef__.fileTypes", "pdf,doc,docx,txt,rtf,djvu,epub"); +pref("extensions.zotero.__addonRef__.autoRemoveEmptyFolder", false); diff --git a/package.json b/package.json index c0fea65..bdf71ec 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "homepage": "https://github.com/muisedestiny/zotero-attanger#readme", "dependencies": { + "string-comparison": "^1.3.0", "zotero-plugin-toolkit": "^2.3.15" }, "devDependencies": { diff --git a/src/addon.ts b/src/addon.ts index f7dfd4c..34b766e 100644 --- a/src/addon.ts +++ b/src/addon.ts @@ -36,6 +36,7 @@ class Addon { icons: { favicon: `chrome://${config.addonRef}/content/icons/favicon.png`, attachNewFile: `chrome://${config.addonRef}/content/icons/attachNewFile.png`, + matchAttachment: `chrome://${config.addonRef}/content/icons/matchAttachment.png`, renameMoveAttachment: `chrome://${config.addonRef}/content/icons/renameMoveAttachment.png`, openUsing: `chrome://${config.addonRef}/content/icons/openUsing.png`, renameAttachment: "chrome://zotero/skin/bookmark-pencil.png", diff --git a/src/hooks.ts b/src/hooks.ts index 4d7ac69..996d6f5 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -2,8 +2,8 @@ import { config } from "../package.json"; import { getString, initLocale } from "./utils/locale"; import { registerPrefsScripts } from "./modules/preferenceScript"; import { createZToolkit } from "./utils/ztoolkit"; -import Menu, { moveFile } from "./modules/menu"; -import { getPref } from "./utils/prefs"; +import Menu from "./modules/menu"; +// import { getPref } from "./utils/prefs"; async function onStartup() { await Promise.all([ diff --git a/src/modules/menu.ts b/src/modules/menu.ts index d3c4304..2ec78c9 100644 --- a/src/modules/menu.ts +++ b/src/modules/menu.ts @@ -3,6 +3,7 @@ import { getString } from "../utils/locale"; import { config } from "../../package.json"; import { getPref, setPref } from "../utils/prefs"; import { waitUntil, waitUtilAsync } from "../utils/wait"; +import comparison from "string-comparison"; export default class Menu { constructor() { @@ -69,7 +70,7 @@ export default class Menu { // Unregister callback when the window closes (important to avoid a memory leak) window.addEventListener( "unload", - (e: Event) => { + (_ev: Event) => { Zotero.Notifier.unregisterObserver(addon.data.notifierID); }, false, @@ -91,6 +92,18 @@ export default class Menu { return items.some((i) => i.isTopLevelItem() || i.isAttachment()); }, }); + ztoolkit.Menu.register("item", { + tag: "menuitem", + label: getString("match-attachment"), + icon: addon.data.icons.matchAttachment, + getVisibility: () => { + const items = ZoteroPane.getSelectedItems(); + return items.some((i) => i.isTopLevelItem() && i.isRegularItem()); + }, + commandListener: async (_ev) => { + await matchAttachment(); + }, + }); // 附加新文件 // 条目 ztoolkit.Menu.register("item", { @@ -106,7 +119,7 @@ export default class Menu { items[0].isRegularItem() ); }, - commandListener: async (ev) => { + commandListener: async (_ev) => { const item = ZoteroPane.getSelectedItems()[0]; await attachNewFile({ libraryID: item.libraryID, @@ -120,18 +133,12 @@ export default class Menu { tag: "menuitem", label: getString("attach-new-file"), icon: addon.data.icons.attachNewFile, - commandListener: async (ev) => { + getVisibility: () => { + return ZoteroPane.getCollectionTreeRow()?.isCollection(); + }, + commandListener: async (_ev) => { const collection = ZoteroPane.getSelectedCollection() as Zotero.Collection; - if (!collection) { - // 非collection暂不处理 - new ztoolkit.ProgressWindow("Attach New File") - .createLine({ - text: "Please select a Zotero Collection", - type: "attachNewFile", - }) - .show(); - } await attachNewFile({ libraryID: collection.libraryID, parentItemID: undefined, @@ -152,7 +159,7 @@ export default class Menu { tag: "menuitem", label: getString("rename-attachment"), icon: addon.data.icons.renameAttachment, - commandListener: async (ev) => { + commandListener: async (_ev) => { for (const item of getAttachmentItems()) { try { const attItem = await renameFile(item); @@ -167,7 +174,7 @@ export default class Menu { tag: "menuitem", label: getString("move-attachment"), icon: addon.data.icons.moveFile, - commandListener: async (ev) => { + commandListener: async (_ev) => { for (const item of getAttachmentItems()) { try { const attItem = await moveFile(item); @@ -220,7 +227,7 @@ export default class Menu { { tag: "menuitem", label: "Zotero", - commandListener: async (ev) => { + commandListener: async (_ev) => { // 第二个参数应该从文件分析得出,默认pdf openUsing("", "pdf"); }, @@ -228,7 +235,7 @@ export default class Menu { { tag: "menuitem", label: "System", - commandListener: async (ev) => { + commandListener: async (_ev) => { openUsing("system", "pdf"); }, }, @@ -237,7 +244,7 @@ export default class Menu { for (const fileHandler of fileHandlerArr) { children.push({ tag: "menuitem", - label: OS.Path.basename(fileHandler), + label: PathUtils.filename(fileHandler), commandListener: async (ev: MouseEvent) => { if (ev.button == 2) { if (window.confirm("Delete?")) { @@ -257,7 +264,7 @@ export default class Menu { { tag: "menuitem", label: getString("choose-other-app"), - commandListener: async (ev) => { + commandListener: async (_ev) => { // #42 Multiple extensions may be included, separated by a semicolon and a space. const filename = await new ztoolkit.FilePicker( "Select Application", @@ -284,19 +291,107 @@ function getAttachmentItems(hasParent = true) { for (const item of ZoteroPane.getSelectedItems()) { if (item.isAttachment() && (hasParent ? !item.isTopLevelItem() : true)) { attachmentItems.push(item); - } else if (item.isRegularItem()) { - for (const id of item.getAttachments()) { - const _item = Zotero.Items.get(id); - if (_item.isAttachment()) { - attachmentItems.push(_item); - } - } + } + else if (item.isRegularItem()) { + item.getAttachments() + .map(id => Zotero.Items.get(id)) + .filter(item => item.isAttachment()) + .forEach(item => attachmentItems.push(item)); } } return attachmentItems; } +async function matchAttachment() { + const items = ZoteroPane.getSelectedItems() + .filter(i => i.isTopLevelItem() && i.isRegularItem()) + .sort((a, b) => getPlainTitle(a).length - getPlainTitle(b).length); + ztoolkit.log("item titles: ", items.map(i => i.getDisplayTitle())); + const sourceDir = await checkDir("sourceDir", "source path"); + if (!sourceDir) return; + let files: OS.File.Entry[] = []; + /* TODO: migrate to IOUtils */ + await Zotero.File.iterateDirectory(sourceDir, async function (child: OS.File.Entry) { + if (!child.isDir && /\.(caj|pdf)$/i.test(child.name)) { + files.push(child); + } + }); + ztoolkit.log("found pdf files:", files.map(f => f.path)); + const readPDFTitle = getPref("readPDFtitle") as string; + ztoolkit.log("read PDF title: ", readPDFTitle); + for (const item of items) { + const itemtitle = getPlainTitle(item); + ztoolkit.log("processing item: ", itemtitle); + let iniDistance = Infinity; + let matched: OS.File.Entry|undefined = undefined; + for (const file of files) { + let filename = file.name.replace(/\..+?$/, ""); + + /* 尝试从PDF元数据或文本中读取标题 */ + try { + if (/pdf/i.test(Zotero.File.getExtension(file.path))) { + throw new Error("This is not a PDF file."); + } + ztoolkit.log("check file:", file.name + ": "); + const data: any = await getPDFData(file.path); + const lines: Array = []; + data.pages.forEach((page: Array) => { + page[page.length - 1][0][0][0][4].forEach((line: Array>>) => { + const lineObj = { fontSize: 0, text: "" }; + line[0].forEach((word) => { + lineObj.fontSize += word[4]; + lineObj.text += word[word.length -1] + ((word[5] > 0) ? " " : ""); + }); + lineObj.fontSize /= line[0].length; + // ztoolkit.log(lineObj); + lines.push(lineObj); + }); + }); + const optTitle = data?.metadata?.title + ? data.metadata.title + : lines.reduce((max, cur) => { + if (cur.fontSize > max.fontSize) { + return cur; + } + else if (cur.fontSize == max.fontSize) { + max.text += ` ${cur.text}`; + } + return max; + }, { fontSize: -Infinity, text: ""}).text.replace(/\s?([\u4e00-\u9fff])\s?/g, "$1"); + ztoolkit.log("optical title: ", optTitle); + if (readPDFTitle != "Never" + && optTitle + && (!/[\u4e00-\u9fff]/.test(itemtitle) || readPDFTitle == "Always") + ) { + filename = cleanLigature(optTitle); + } + } + catch(e: any) { + ztoolkit.log(e); + } + ztoolkit.log("filename:", filename); + const distance = comparison.metricLcs.distance(itemtitle.toLowerCase(), filename.toLowerCase()); + ztoolkit.log(`【${itemtitle}】 × 【${filename}】 => ${distance}`); + if (distance <= iniDistance) { + iniDistance = distance; + matched = file; + } + } + if (matched !== undefined) { + const attItem = await Zotero.Attachments.importFromFile({ + file: matched.path, + libraryID: item.libraryID, + parentItemID: item.id, + }); + item.addTag("\\auto matched attachment"); + item.save(); + showAttachmentItem(attItem); + files = files.filter(file => file !== matched); + } + } +} + async function openUsing(fileHandler: string, fileType = "pdf") { const _fileHandler = Zotero.Prefs.get(`fileHandler.${fileType}`) as string; Zotero.Prefs.set(`fileHandler.${fileType}`, fileHandler); @@ -306,7 +401,8 @@ async function openUsing(fileHandler: string, fileType = "pdf") { selectedItems.map(async (item: Zotero.Item) => { if (item.isAttachment()) { ids.push(item.id); - } else { + } + else { ids.push((await item.getBestAttachments())[0].id); } }), @@ -383,19 +479,9 @@ export async function moveFile(attItem: Zotero.Item) { if (!checkFileType(attItem)) { return; } + let destDir = await checkDir("destDir", "destination directory"); // 1. 目标根路径 - let destDir: string | boolean = getPref("destDir") as string; - if (!destDir) { - destDir = await new ztoolkit.FilePicker( - "Select Destination Directory", - "folder", - ).open(); - if (destDir) { - setPref("destDir", destDir); - } else { - return; - } - } + if (!destDir) return; // 2. 中间路径 let subfolder = ""; const subfolderFormat = getPref("subfolderFormat") as string; @@ -428,19 +514,15 @@ export async function moveFile(attItem: Zotero.Item) { // @ts-ignore 未添加属性 Zotero.File.getValidFileName = _getValidFileName; ztoolkit.log("subfolder", subfolder); - destDir = OS.Path.join(destDir, subfolder); + destDir = PathUtils.join(destDir, subfolder); } const sourcePath = (await attItem.getFilePathAsync()) as string; - if (!sourcePath) { - return; - } - const filename = OS.Path.basename(sourcePath); - let destPath = OS.Path.join(destDir, filename); - if (sourcePath == destPath) { - return; - } + if (!sourcePath) return; + const filename = PathUtils.filename(sourcePath); + let destPath = PathUtils.join(destDir, filename); + if (sourcePath == destPath) return; // window.alert(destPath) - if (await OS.File.exists(destPath)) { + if (await IOUtils.exists(destPath)) { await Zotero.Promise.delay(1000); // Click to enter a specified suffix. const popupWin = new ztoolkit.ProgressWindow("Attanger", { @@ -482,12 +564,12 @@ export async function moveFile(attItem: Zotero.Item) { destPath = await addSuffixToFilename(destPath); } // 创建中间路径 - if (!(await OS.File.exists(destDir))) { + if (!(await IOUtils.exists(destDir))) { const create = [destDir]; - let parent = OS.Path.dirname(destDir); - while (!(await OS.File.exists(parent))) { + let parent = PathUtils.parent(destDir); + while (parent && !(await IOUtils.exists(parent))) { create.push(parent); - parent = OS.Path.dirname(parent); + parent = PathUtils.parent(parent); } await Promise.all( create @@ -498,8 +580,9 @@ export async function moveFile(attItem: Zotero.Item) { // await Zotero.File.createDirectoryIfMissingAsync(destDir); // 移动文件到目标文件夹 try { - await OS.File.move(sourcePath, destPath); - } catch (e) { + await IOUtils.move(sourcePath, destPath); + } + catch (e) { ztoolkit.log(e); return await moveFile(attItem); } @@ -513,7 +596,7 @@ export async function moveFile(attItem: Zotero.Item) { window.setTimeout(async () => { // 迁移标注 await transferItem(attItem, newAttItem); - removeEmptyFolder(OS.Path.dirname(sourcePath)); + removeEmptyFolder(PathUtils.parent(sourcePath) as string); await attItem.eraseTx(); }); return newAttItem; @@ -524,24 +607,15 @@ async function attachNewFile(options: { parentItemID: number | undefined; collections: number[] | undefined; }) { - let sourceDir: string | boolean = getPref("sourceDir") as string; - if (!(await OS.File.exists(sourceDir))) { - sourceDir = await new ztoolkit.FilePicker( - "Select Source Directory", - "folder", - ).open(); - if (sourceDir) { - setPref("sourceDir", sourceDir); - } else { - return; - } - } + const sourceDir = await checkDir("sourceDir", "source path"); + if (!sourceDir) return; const path = getLastFileInFolder(sourceDir); if (!path) { new ztoolkit.ProgressWindow(config.addonName) .createLine({ text: "No File Found", type: "default" }) .show(); - } else { + } + else { const attItem = await Zotero.Attachments.importFromFile({ file: path, ...options, @@ -592,7 +666,7 @@ function getCollectionPathsOfItem(item: Zotero.Item) { if (!collection.parentID) { return collection.name; } - return OS.Path.normalize( + return PathUtils.normalize( getCollectionPath(collection.parentID) + addon.data.folderSep + collection.name, @@ -610,7 +684,7 @@ function checkFileType(attItem: Zotero.Item) { if (!fileTypes) return true; const pos = attItem.attachmentFilename.lastIndexOf("."), fileType = - pos == -1 ? "" : attItem.attachmentFilename.substr(pos + 1).toLowerCase(), + pos == -1 ? "" : attItem.attachmentFilename.substring(pos + 1).toLowerCase(), regex = fileTypes.toLowerCase().replace(/,/gi, "|"); // return value return fileType.search(new RegExp(regex)) >= 0 ? true : false; @@ -667,13 +741,16 @@ function showAttachmentItem(attItem: Zotero.Item) { /** * Remove empty folders recursively within zotfile directories - * @param {String|nsIFile} folder Folder as nsIFile. + * @param {String|nsIFile} path Folder as nsIFile. * @return {void} */ -async function removeEmptyFolder(path: string) { +async function removeEmptyFolder(path: string|nsIFile) { if (!getPref("autoRemoveEmptyFolder") as boolean) { return false; } + if (!path as boolean) { + return false; + } const folder = Zotero.File.pathToFile(path); let rootFolders = [Zotero.getStorageDirectory().path]; const source_dir = getPref("sourceDir") as string; @@ -684,7 +761,7 @@ async function removeEmptyFolder(path: string) { if (dest_dir != "") { rootFolders.push(dest_dir); } - rootFolders = rootFolders.map((path) => OS.Path.normalize(path)); + rootFolders = rootFolders.map((path) => PathUtils.normalize(path)); // 不属于插件相关根目录,不处理 if (!rootFolders.find((dir) => folder.path.startsWith(dir))) { return false; @@ -693,7 +770,7 @@ async function removeEmptyFolder(path: string) { return true; } else { removeFile(folder, true); - return await removeEmptyFolder(OS.Path.dirname(folder.path)); + return await removeEmptyFolder(PathUtils.parent(folder.path) as string); } } @@ -755,10 +832,91 @@ async function addSuffixToFilename(filename: string, suffix?: string) { destPath = destName; // 假设 destPath 是目标文件路径 // 检查文件是否存在 - if (await OS.File.exists(destPath)) { + if (await IOUtils.exists(destPath)) { incr++; } else { return destPath; } } } + +async function checkDir(prefName: string, prefDisplay: string) { + let dir = getPref(prefName); + if (typeof dir !== "string" || !await IOUtils.exists(dir)) { + dir = await new ztoolkit.FilePicker( + `Select ${prefDisplay}`, + "folder", + ).open(); + if (typeof dir === "string") { + setPref(prefName, dir); + return dir; + } + else { + new ztoolkit.ProgressWindow(config.addonName) + .createLine({ text: "No valid path set", type: "default" }) + .show(); + return false; + } + } + return dir; +} + +/** + * 清除文件名中的格式标记,返回纯文本的标题。 + * 虽然通常用于与文件名进行比较,但并不调用Zotero.File.getValidFileName进行规范化。 + */ +function getPlainTitle(item: Zotero.Item) { + return item.getDisplayTitle().replace(/<(?:i|b|sub|sub)>(.+?)<\/(?:i|b|sub|sub)>/g, "$1"); +} + +function cleanLigature(filename:string) { + let result = filename; + interface StringMap { + [key: string]: string + } + const ligature: StringMap = { + "æ": "ae", + "Æ": "AE", + "œ": "oe", + "Œ": "OE", + "ff": "ff", + "fi": "fi", + "fl": "fl", + "ffi": "ffi", + "ffl": "ffl" + } + Object.keys(ligature).forEach((key) => { + result = result.replace(new RegExp(key, 'g'), ligature[key]) + }); + return result; +} + +/** + * 对Zotero.PDFWorker.getRecognizerData的重写,以便支持直接给出路径。 + */ +async function getPDFData(path: string) { + return Zotero.PDFWorker._enqueue(async () => { + const buf = new Uint8Array(await IOUtils.read(path)).buffer; + let result = {}; + try { + result = await Zotero.PDFWorker._query('getRecognizerData', { buf }, [buf]); + } + catch (e: any) { + const error = new Error(`Worker 'getRecognizerData' failed: ${JSON.stringify({ error: e.message })}`); + try { + error.name = JSON.parse(e.message).name; + } + catch (e: any) { + ztoolkit.log(e); + } + ztoolkit.log(error); + throw error; + } + + ztoolkit.log(`Extracted PDF recognizer data for path ${path}`); + + return result; + }, false); +} + + diff --git a/src/utils/ztoolkit.ts b/src/utils/ztoolkit.ts index b2fd80d..ecf9698 100644 --- a/src/utils/ztoolkit.ts +++ b/src/utils/ztoolkit.ts @@ -1,4 +1,4 @@ -import ZoteroToolkit from "../../../zotero-plugin-toolkit"; +import ZoteroToolkit from "zotero-plugin-toolkit"; import { config } from "../../package.json"; export { createZToolkit };