修改可发布的xpi的插件包

This commit is contained in:
yehongyu
2026-07-20 15:55:03 +08:00
parent b784a7e2fb
commit 3d5ffa1cb6
14 changed files with 1066 additions and 96 deletions
+12 -20
View File
@@ -3,6 +3,7 @@ import { getString } from "../../utils/locale";
import { openMultiChatDialog } from "./multiChatDialog";
const MENU_CHAT_ID = "chatpapers-itemmenu-chat";
/** Legacy duplicate menu — unregister if still present from older builds. */
const MENU_COMPARE_ID = "chatpapers-itemmenu-compare";
function getSelectedItems(): Zotero.Item[] {
@@ -15,7 +16,18 @@ function getSelectedItems(): Zotero.Item[] {
}
}
function removeMenuById(id: string) {
try {
const doc = Zotero.getMainWindow()?.document;
doc?.getElementById(id)?.remove();
} catch {
// ignore
}
}
export function registerItemMenus() {
removeMenuById(MENU_COMPARE_ID);
const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`;
ztoolkit.Menu.register("item", {
@@ -28,24 +40,4 @@ export function registerItemMenus() {
openMultiChatDialog(items);
},
});
ztoolkit.Menu.register("item", {
tag: "menuitem",
id: MENU_COMPARE_ID,
label: getString("itemmenu-compare"),
icon,
commandListener: () => {
const items = getSelectedItems();
if (items.length < 2) {
new ztoolkit.ProgressWindow("ChatPapers")
.createLine({
text: getString("multi-need-two"),
type: "fail",
})
.show();
return;
}
openMultiChatDialog(items);
},
});
}
+2 -5
View File
@@ -198,15 +198,12 @@ export function openMultiChatDialog(items: Zotero.Item[]) {
});
activeDialog = dialogHelper;
dialogHelper.open(
`ChatPapers · ${Math.min(selected.length, 8)} papers`,
{
dialogHelper.open("ChatPapers", {
centerscreen: true,
resizable: true,
noDialogMode: true,
fitContent: false,
width: size.width,
height: size.height,
},
);
});
}
+359 -40
View File
@@ -1,15 +1,19 @@
import {
Bot,
ChevronDown,
Copy,
Download,
GitCompare,
Layers,
ListTree,
MessageSquareText,
NotebookPen,
Plus,
SendHorizontal,
Sparkles,
Square,
User,
X,
type IconNode,
} from "lucide";
import { chatStream, getRuntimeConfig } from "../llm/client";
@@ -21,6 +25,13 @@ import {
type MultiChatMode,
} from "../pdf/multiContext";
import { createChildNote } from "../zotero/notes";
import {
defaultCiteFormat,
formatCiteText,
listCiteFormatOptions,
saveCiteToFile,
type CiteFormatOption,
} from "../zotero/citeExport";
import { getString } from "../../utils/locale";
import { markdownToHtml } from "../../utils/markdown";
import { createLucideIcon } from "../../utils/icons";
@@ -29,6 +40,11 @@ import {
isAbortError,
type AbortHandle,
} from "../../utils/abort";
import { pickLibraryItems } from "./pickItems";
const MAX_PAPERS = 8;
let rememberedCiteFormat = "";
export class MultiChatView {
private body: HTMLElement;
@@ -36,6 +52,7 @@ export class MultiChatView {
private items: Zotero.Item[];
private messages: ChatMessage[] = [];
private abort?: AbortHandle;
private citeFormats: CiteFormatOption[] = [];
private messagesEl!: HTMLElement;
private inputEl!: HTMLTextAreaElement;
@@ -44,11 +61,22 @@ export class MultiChatView {
private sendBtn!: HTMLButtonElement;
private stopBtn!: HTMLButtonElement;
private emptyEl!: HTMLElement;
private paperListEl!: HTMLElement;
private citeBarEl!: HTMLElement;
private citePickerEl!: HTMLElement;
private citeTriggerEl!: HTMLButtonElement;
private citeTriggerLabelEl!: HTMLElement;
private citeMenuEl!: HTMLElement;
private citeValue = "";
private titleEl!: HTMLElement;
private compareBtn!: HTMLButtonElement;
private summaryEachBtn!: HTMLButtonElement;
private commonBtn!: HTMLButtonElement;
constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) {
this.doc = doc;
this.body = body;
this.items = normalizeLibraryItems(items);
this.items = normalizeLibraryItems(items).slice(0, MAX_PAPERS);
}
async mount(): Promise<void> {
@@ -66,22 +94,13 @@ export class MultiChatView {
size: 18,
className: "chatpapers-brand-icon",
}),
this.el(
"div",
"chatpapers-brand-text",
getString("multi-title"),
),
this.el("div", "chatpapers-brand-text", getString("multi-title")),
);
const titles = this.items
.map((it) => it.getField("title") || it.attachmentFilename || `#${it.id}`)
.slice(0, 6);
const more =
this.items.length > 6 ? `${this.items.length}` : `${this.items.length} 篇)`;
this.titleEl = this.el("div", "chatpapers-title");
header.append(
brand,
this.el("div", "chatpapers-title", titles.join(" · ") + more),
this.titleEl,
this.el(
"div",
"chatpapers-meta",
@@ -89,20 +108,13 @@ export class MultiChatView {
),
);
const paperList = this.el("div", "chatpapers-paper-chips");
for (const [i, item] of this.items.entries()) {
const chip = this.el(
"span",
"chatpapers-chip",
`${i + 1}. ${item.getField("title") || item.attachmentFilename || item.id}`,
);
paperList.append(chip);
}
this.paperListEl = this.el("div", "chatpapers-paper-chips");
this.citeBarEl = this.buildCiteBar();
this.messagesEl = this.el("div", "chatpapers-messages");
this.emptyEl = this.el("div", "chatpapers-empty");
this.emptyEl.append(
createLucideIcon(this.doc, GitCompare, {
createLucideIcon(this.doc, MessageSquareText, {
size: 28,
className: "chatpapers-empty-icon",
}),
@@ -115,19 +127,24 @@ export class MultiChatView {
this.statusEl.append(this.statusTextEl);
const toolbar = this.el("div", "chatpapers-toolbar");
this.compareBtn = this.iconBtn(GitCompare, getString("multi-compare"), () =>
this.runModeGuarded("compare", getString("multi-compare")),
);
this.summaryEachBtn = this.iconBtn(
ListTree,
getString("multi-summary-each"),
() => this.runModeGuarded("summary-each", getString("multi-summary-each")),
);
this.commonBtn = this.iconBtn(Layers, getString("multi-common"), () =>
this.runModeGuarded("common-themes", getString("multi-common")),
);
toolbar.append(
this.iconBtn(NotebookPen, getString("multi-topic-review"), () =>
void this.runTopicReview(),
),
this.iconBtn(GitCompare, getString("multi-compare"), () =>
this.runMode("compare", getString("multi-compare")),
),
this.iconBtn(ListTree, getString("multi-summary-each"), () =>
this.runMode("summary-each", getString("multi-summary-each")),
),
this.iconBtn(Layers, getString("multi-common"), () =>
this.runMode("common-themes", getString("multi-common")),
),
this.compareBtn,
this.summaryEachBtn,
this.commonBtn,
this.iconBtn(Sparkles, getString("chat-save-note"), () =>
this.saveLastNote(),
),
@@ -163,14 +180,17 @@ export class MultiChatView {
this.body.append(
header,
paperList,
this.paperListEl,
this.citeBarEl,
this.messagesEl,
this.statusEl,
toolbar,
composer,
);
this.refreshPaperChrome();
this.renderMessages();
void this.loadCiteFormats();
if (!cfg.provider || !cfg.baseUrl) {
this.setStatus(getString("chat-need-config"), "warn");
} else {
@@ -183,9 +203,186 @@ export class MultiChatView {
destroy(): void {
this.stop();
try {
const onDocClick = (this.citePickerEl as any)?._onDocClick;
if (onDocClick) this.doc.removeEventListener("click", onDocClick, true);
} catch {
// ignore
}
this.body.replaceChildren();
}
private buildCiteBar(): HTMLElement {
const bar = this.el("div", "chatpapers-cite-bar");
const label = this.el(
"span",
"chatpapers-cite-label",
getString("cite-style"),
);
// Custom picker — native <select> breaks in Zotero dialogs (SelectParent / menupopup).
this.citePickerEl = this.el("div", "chatpapers-cite-picker");
this.citeTriggerEl = this.doc.createElement("button");
this.citeTriggerEl.type = "button";
this.citeTriggerEl.className = "chatpapers-cite-trigger";
this.citeTriggerEl.title = getString("cite-style");
this.citeTriggerLabelEl = this.el(
"span",
"chatpapers-cite-trigger-label",
getString("cite-loading"),
);
this.citeTriggerEl.append(
this.citeTriggerLabelEl,
createLucideIcon(this.doc, ChevronDown, {
size: 14,
className: "chatpapers-cite-chevron",
}),
);
this.citeMenuEl = this.el("div", "chatpapers-cite-menu");
this.citeMenuEl.hidden = true;
this.citePickerEl.append(this.citeTriggerEl, this.citeMenuEl);
this.citeTriggerEl.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.toggleCiteMenu();
});
const onDocClick = (ev: Event) => {
const t = ev.target as Node | null;
if (!t || this.citePickerEl.contains(t)) return;
this.closeCiteMenu();
};
this.doc.addEventListener("click", onDocClick, true);
// Store for cleanup via destroy
(this.citePickerEl as any)._onDocClick = onDocClick;
const copyBtn = this.iconBtn(Copy, getString("cite-copy"), () =>
void this.copyCite(),
);
copyBtn.classList.add("chatpapers-cite-btn");
const exportBtn = this.iconBtn(Download, getString("cite-export"), () =>
void this.exportCite(),
);
exportBtn.classList.add("chatpapers-cite-btn");
bar.append(label, this.citePickerEl, copyBtn, exportBtn);
return bar;
}
private toggleCiteMenu() {
if (this.citeMenuEl.hidden) this.openCiteMenu();
else this.closeCiteMenu();
}
private openCiteMenu() {
this.citeMenuEl.hidden = false;
this.citePickerEl.classList.add("is-open");
}
private closeCiteMenu() {
this.citeMenuEl.hidden = true;
this.citePickerEl.classList.remove("is-open");
}
private setCiteValue(value: string, label?: string) {
this.citeValue = value;
rememberedCiteFormat = value;
const found = this.citeFormats.find((f) => f.value === value);
this.citeTriggerLabelEl.textContent =
label || found?.label || getString("cite-empty");
this.citeTriggerEl.disabled = !value;
}
private async loadCiteFormats() {
try {
this.citeFormats = await listCiteFormatOptions();
this.citeMenuEl.replaceChildren();
if (!this.citeFormats.length) {
this.setCiteValue("", getString("cite-empty"));
return;
}
for (const fmt of this.citeFormats) {
const opt = this.doc.createElement("button");
opt.type = "button";
opt.className = "chatpapers-cite-option";
opt.dataset.value = fmt.value;
opt.textContent = fmt.label;
opt.title = fmt.label;
opt.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setCiteValue(fmt.value, fmt.label);
this.closeCiteMenu();
this.highlightCiteOption(fmt.value);
});
this.citeMenuEl.append(opt);
}
const preferred =
(rememberedCiteFormat &&
this.citeFormats.some((f) => f.value === rememberedCiteFormat) &&
rememberedCiteFormat) ||
defaultCiteFormat(this.citeFormats);
this.setCiteValue(preferred);
this.highlightCiteOption(preferred);
} catch (e) {
ztoolkit.log("loadCiteFormats failed", e);
this.citeMenuEl.replaceChildren();
this.setCiteValue("", getString("cite-empty"));
}
}
private highlightCiteOption(value: string) {
for (const node of Array.from(this.citeMenuEl.children)) {
const btn = node as HTMLElement;
btn.classList.toggle("is-active", btn.dataset.value === value);
}
}
private currentCiteFormat(): string {
return this.citeValue || rememberedCiteFormat || "";
}
private async copyCite() {
const format = this.currentCiteFormat();
if (!format) {
this.setStatus(getString("cite-empty"), "warn");
return;
}
try {
const { text } = await formatCiteText(this.items, format);
rememberedCiteFormat = format;
try {
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
} catch {
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
}
this.setStatus(getString("cite-copied"), "info");
} catch (e) {
this.setStatus(String(e), "error");
}
}
private async exportCite() {
const format = this.currentCiteFormat();
if (!format) {
this.setStatus(getString("cite-empty"), "warn");
return;
}
try {
const { text, ext } = await formatCiteText(this.items, format);
rememberedCiteFormat = format;
const stamp = new Date().toISOString().slice(0, 10);
const filename = `chatpapers-refs-${stamp}.${ext}`;
const win = this.doc.defaultView || Zotero.getMainWindow();
const saved = await saveCiteToFile(win as Window, filename, text);
if (saved) this.setStatus(getString("cite-exported"), "info");
else this.setStatus(getString("cite-export-cancel"), "warn");
} catch (e) {
this.setStatus(String(e), "error");
}
}
private el(tag: string, className?: string, text?: string): HTMLElement {
const node = this.doc.createElement(tag);
if (className) node.className = className;
@@ -218,6 +415,124 @@ export class MultiChatView {
this.statusEl.dataset.kind = kind;
}
private itemLabel(item: Zotero.Item): string {
return (
item.getField("title") ||
item.attachmentFilename ||
`#${item.id}`
);
}
private refreshPaperChrome() {
const titles = this.items.map((it) => this.itemLabel(it)).slice(0, 4);
const more =
this.items.length > 4
? `${this.items.length}`
: `${this.items.length} 篇)`;
this.titleEl.textContent = titles.join(" · ") + more;
this.paperListEl.replaceChildren();
for (const [i, item] of this.items.entries()) {
const chip = this.el("span", "chatpapers-chip");
const label = this.el(
"span",
"chatpapers-chip-label",
`${i + 1}. ${this.itemLabel(item)}`,
);
label.title = this.itemLabel(item);
chip.append(label);
if (this.items.length > 1) {
const remove = this.doc.createElement("button");
remove.type = "button";
remove.className = "chatpapers-chip-remove";
remove.title = getString("multi-remove-paper");
remove.append(createLucideIcon(this.doc, X, { size: 11 }));
remove.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.removeItem(item.id);
});
chip.append(remove);
}
this.paperListEl.append(chip);
}
const addBtn = this.doc.createElement("button");
addBtn.type = "button";
addBtn.className = "chatpapers-chip chatpapers-chip-add";
addBtn.title = getString("multi-add-paper");
addBtn.append(
createLucideIcon(this.doc, Plus, { size: 12 }),
this.el("span", undefined, getString("multi-add-paper")),
);
addBtn.disabled = this.items.length >= MAX_PAPERS;
addBtn.addEventListener("click", () => void this.addPapersFromLibrary());
this.paperListEl.append(addBtn);
const multiOk = this.items.length >= 2;
for (const btn of [this.compareBtn, this.summaryEachBtn, this.commonBtn]) {
btn.disabled = !multiOk;
btn.title = multiOk
? btn.querySelector(".chatpapers-btn-label")?.textContent || ""
: getString("multi-need-two");
}
}
private removeItem(id: number) {
if (this.items.length <= 1) return;
this.items = this.items.filter((it) => it.id !== id);
this.refreshPaperChrome();
this.setStatus(
getString("multi-paper-removed", { args: { count: this.items.length } }),
"info",
);
}
private async addPapersFromLibrary() {
if (this.items.length >= MAX_PAPERS) {
this.setStatus(
getString("multi-too-many", { args: { count: this.items.length } }),
"warn",
);
return;
}
try {
const picked = await pickLibraryItems({ multiSelect: true });
if (!picked.length) return;
const existing = new Set(this.items.map((i) => i.id));
const incoming = normalizeLibraryItems(picked).filter(
(it) => !existing.has(it.id),
);
if (!incoming.length) {
this.setStatus(getString("multi-add-none"), "warn");
return;
}
const room = MAX_PAPERS - this.items.length;
const toAdd = incoming.slice(0, room);
this.items = [...this.items, ...toAdd];
this.refreshPaperChrome();
if (incoming.length > room) {
this.setStatus(
getString("multi-too-many", { args: { count: this.items.length } }),
"warn",
);
} else {
this.setStatus(
getString("multi-paper-added", {
args: { added: toAdd.length, count: this.items.length },
}),
"info",
);
}
} catch (e) {
this.setStatus(String(e), "error");
}
}
private renderMessages() {
this.messagesEl.replaceChildren();
const visible = this.messages.filter((m) => m.role !== "system");
@@ -321,11 +636,14 @@ export class MultiChatView {
await this.run("chat", text);
}
private async runMode(mode: MultiChatMode, label: string) {
await this.run(mode, label);
private runModeGuarded(mode: MultiChatMode, label: string) {
if (this.items.length < 2) {
this.setStatus(getString("multi-need-two"), "warn");
return;
}
void this.run(mode, label);
}
/** Topic-oriented lit review for writing; topic comes from the input box. */
private async runTopicReview() {
const topic = this.inputEl.value.trim();
if (!topic) {
@@ -403,13 +721,14 @@ export class MultiChatView {
}
const parent = this.items.find((i) => i.isRegularItem()) || this.items[0];
if (!parent) return;
const noteParent = parent.isAttachment() && parent.parentItemID
? Zotero.Items.get(parent.parentItemID) || parent
: parent;
const noteParent =
parent.isAttachment() && parent.parentItemID
? Zotero.Items.get(parent.parentItemID) || parent
: parent;
try {
await createChildNote({
parentItem: noteParent,
title: `ChatPapers 多篇对照 · ${new Date().toLocaleString()}`,
title: `ChatPapers 对话 · ${new Date().toLocaleString()}`,
bodyMarkdown: last.content,
});
this.setStatus(getString("chat-note-saved"), "info");
+56
View File
@@ -0,0 +1,56 @@
/**
* Open Zotero's built-in Select Items dialog and return chosen library items.
*/
export async function pickLibraryItems(options?: {
multiSelect?: boolean;
}): Promise<Zotero.Item[]> {
const multiSelect = options?.multiSelect !== false;
const mainWin = Zotero.getMainWindow?.() as
| (Window & { openDialog?: (...args: any[]) => Window | null })
| undefined;
if (!mainWin?.openDialog) {
throw new Error("无法打开文献选择窗口");
}
const io: {
multiSelect: boolean;
onlyRegularItems: boolean;
dataOut: number[] | null;
itemTreeID: string;
} = {
multiSelect,
onlyRegularItems: true,
dataOut: null,
itemTreeID: "chatpapers-select-items",
};
const urls = [
"chrome://zotero/content/selectItemsDialog.xhtml",
"chrome://zotero/content/selectItemsDialog.xul",
];
let opened = false;
for (const url of urls) {
try {
mainWin.openDialog(
url,
"chatpapers-select-items-dialog",
"chrome,modal,centerscreen,resizable=yes",
io,
);
opened = true;
break;
} catch (e) {
ztoolkit.log("pickLibraryItems openDialog failed", url, e);
}
}
if (!opened) {
throw new Error("当前 Zotero 版本无法打开文献选择对话框");
}
const ids = Array.isArray(io.dataOut) ? io.dataOut : [];
if (!ids.length) return [];
return (Zotero.Items.get(ids).filter(Boolean) as Zotero.Item[]) || [];
}
+326
View File
@@ -0,0 +1,326 @@
import { normalizeLibraryItems } from "../pdf/multiContext";
export interface CiteFormatOption {
/** QuickCopy / export key, e.g. export:<id> or bibliography:<styleID> */
value: string;
label: string;
kind: "export" | "bibliography";
/** Lower sorts first within priority band */
sortKey: string;
/** 0 = BibTeX/LaTeX, 1 = GB/T 7714, 2 = user quick-copy, 3 = other */
band: number;
}
const BIBTEX_ID = "9cb70025-a888-4a29-a210-93ec52da40d4";
const BIBLATEX_ID = "b6e39b57-8942-4d11-8259-342c46ce395f";
/** Prefer Better BibTeX / built-in BibTeX when available. */
async function resolveTranslatorId(
preferredLabels: string[],
fallbackId?: string,
): Promise<string | null> {
try {
const translators = await (Zotero.Translators as any).getAllForType(
"export",
);
const list = (translators || []) as Array<{
translatorID: string;
label: string;
}>;
for (const want of preferredLabels) {
const exact = list.find(
(t) => t.label.toLowerCase() === want.toLowerCase(),
);
if (exact) return exact.translatorID;
}
for (const want of preferredLabels) {
const fuzzy = list.find((t) =>
t.label.toLowerCase().includes(want.toLowerCase()),
);
if (fuzzy) return fuzzy.translatorID;
}
} catch (e) {
ztoolkit.log("resolveTranslatorId failed", e);
}
return fallbackId || null;
}
function isGbtStyle(id: string, title: string): boolean {
const s = `${id} ${title}`.toLowerCase();
return (
s.includes("7714") ||
s.includes("gb-t") ||
s.includes("gbt") ||
s.includes("china-national-standard")
);
}
/** Regular parent items suitable for citation export. */
export function toCiteItems(items: Zotero.Item[]): Zotero.Item[] {
const map = new Map<number, Zotero.Item>();
for (const raw of normalizeLibraryItems(items)) {
let item = raw;
if (item.isAttachment() && item.parentItemID) {
const parent = Zotero.Items.get(item.parentItemID);
if (parent) item = parent;
}
if (item.isRegularItem()) map.set(item.id, item);
}
return [...map.values()];
}
/**
* Build dropdown options: BibTeX/BibLaTeX first, then GB/T 7714,
* then the user's Quick Copy style, then other installed CSL styles.
*/
export async function listCiteFormatOptions(): Promise<CiteFormatOption[]> {
try {
await (Zotero.Schema as any).schemaUpdatePromise;
} catch {
try {
await (Zotero.Schema as any).schemeUpdatePromise;
} catch {
// ignore
}
}
const options: CiteFormatOption[] = [];
const bibtexId = await resolveTranslatorId(
["Better BibTeX", "BibTeX"],
BIBTEX_ID,
);
if (bibtexId) {
options.push({
value: `export:${bibtexId}`,
label: "BibTeX (LaTeX)",
kind: "export",
sortKey: "0-bibtex",
band: 0,
});
}
const biblatexId = await resolveTranslatorId(
["Better BibLaTeX", "BibLaTeX"],
BIBLATEX_ID,
);
if (biblatexId && biblatexId !== bibtexId) {
options.push({
value: `export:${biblatexId}`,
label: "BibLaTeX (LaTeX)",
kind: "export",
sortKey: "1-biblatex",
band: 0,
});
}
let quickCopyStyleId = "";
try {
const setting = String(Zotero.Prefs.get("export.quickCopy.setting") || "");
if (setting.startsWith("bibliography=")) {
quickCopyStyleId = setting.slice("bibliography=".length);
}
} catch {
// ignore
}
try {
const styles = (Zotero.Styles as any).getVisible() as Array<{
styleID: string;
title: string;
}>;
for (const style of styles || []) {
const id = style.styleID;
const title = style.title || id;
if (!id) continue;
let band = 3;
if (isGbtStyle(id, title)) band = 1;
else if (quickCopyStyleId && id === quickCopyStyleId) band = 2;
options.push({
value: `bibliography:${id}`,
label: band === 1 ? `GB/T · ${title}` : title,
kind: "bibliography",
sortKey: title.toLowerCase(),
band,
});
}
} catch (e) {
ztoolkit.log("listCiteFormatOptions styles failed", e);
}
options.sort((a, b) => {
if (a.band !== b.band) return a.band - b.band;
return a.sortKey.localeCompare(b.sortKey, "zh");
});
return options;
}
function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, "\n\n")
.trim();
}
async function exportViaTranslator(
items: Zotero.Item[],
translatorID: string,
): Promise<string> {
const TranslateExport = (Zotero as any).Translate?.Export;
if (!TranslateExport) {
throw new Error("当前环境不支持导出翻译器");
}
const translation = new TranslateExport();
translation.setItems(items);
translation.setTranslator(translatorID);
translation.setDisplayOptions?.({ exportNotes: false });
return await new Promise<string>((resolve, reject) => {
let settled = false;
translation.setHandler("done", (_obj: any, success: boolean) => {
if (settled) return;
settled = true;
if (!success) {
reject(new Error("导出失败"));
return;
}
const text =
(typeof translation.string === "string" && translation.string) ||
(typeof _obj?.string === "string" && _obj.string) ||
"";
resolve(text);
});
translation.setHandler("error", (_obj: any, err: unknown) => {
if (settled) return;
settled = true;
reject(err instanceof Error ? err : new Error(String(err)));
});
try {
const maybe = translation.translate();
if (maybe && typeof maybe.then === "function") {
maybe.then((result: any) => {
if (settled) return;
if (typeof result === "string" && result) {
settled = true;
resolve(result);
}
}).catch((e: unknown) => {
if (settled) return;
settled = true;
reject(e instanceof Error ? e : new Error(String(e)));
});
}
} catch (e) {
if (!settled) {
settled = true;
reject(e instanceof Error ? e : new Error(String(e)));
}
}
});
}
async function quickCopyContent(
items: Zotero.Item[],
format: string,
): Promise<{ text?: string; html?: string }> {
const qc = Zotero.QuickCopy as any;
let result = qc.getContentFromItems(items, format);
if (result && typeof result.then === "function") {
result = await result;
}
// Some builds expose getContentFromItemsAsync
if (!result?.text && !result?.html && typeof qc.getContentFromItemsAsync === "function") {
result = await qc.getContentFromItemsAsync(items, format);
}
return result || {};
}
/** Format current papers as plain text for the selected cite format. */
export async function formatCiteText(
items: Zotero.Item[],
formatValue: string,
): Promise<{ text: string; ext: string; mimeHint: string }> {
const citeItems = toCiteItems(items);
if (!citeItems.length) {
throw new Error("没有可导出的文献条目");
}
if (formatValue.startsWith("export:")) {
const translatorID = formatValue.slice("export:".length);
let text = "";
try {
const result = await quickCopyContent(
citeItems,
`export=${translatorID}`,
);
text = result.text || "";
} catch {
text = "";
}
if (!text.trim()) {
text = await exportViaTranslator(citeItems, translatorID);
}
if (!text.trim()) throw new Error("BibTeX 导出结果为空");
return { text, ext: "bib", mimeHint: "application/x-bibtex" };
}
const styleId = formatValue.startsWith("bibliography:")
? formatValue.slice("bibliography:".length)
: formatValue;
const format = `bibliography=${styleId}`;
const result = await quickCopyContent(citeItems, format);
const text =
(result.text && result.text.trim()) ||
(result.html ? stripHtml(result.html) : "");
if (!text) throw new Error("参考文献生成结果为空(请确认已安装该样式)");
return { text, ext: "txt", mimeHint: "text/plain" };
}
export async function saveCiteToFile(
parentWin: Window,
filename: string,
content: string,
): Promise<boolean> {
const FilePickerCtor =
(parentWin as any).FilePicker ||
(ztoolkit.getGlobal("FilePicker" as any) as any);
if (!FilePickerCtor) {
// Fallback: copy only path unavailable
throw new Error("无法打开另存为对话框");
}
const fp = new FilePickerCtor();
fp.init(parentWin, "ChatPapers", fp.modeSave);
const isBib = /\.bib$/i.test(filename);
if (isBib) {
fp.appendFilter("BibTeX", "*.bib");
}
fp.appendFilter("Text", "*.txt");
fp.appendFilters(fp.filterAll);
fp.defaultString = filename;
const rv = await fp.show();
if (rv !== fp.returnOK && rv !== fp.returnReplace) {
return false;
}
const path = fp.file;
if (!path) return false;
await Zotero.File.putContentsAsync(path, content);
return true;
}
export function defaultCiteFormat(options: CiteFormatOption[]): string {
if (!options.length) return "";
const bib = options.find((o) => o.band === 0);
if (bib) return bib.value;
const gbt = options.find((o) => o.band === 1);
if (gbt) return gbt.value;
return options[0].value;
}