diff --git a/src/lib/clean-pdf/__tests__/api-key.test.ts b/src/lib/clean-pdf/__tests__/api-key.test.ts new file mode 100644 index 0000000..1c74a96 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/api-key.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key"; + +describe("generatePdfKey", () => { + it("формат sbpdf_, достаточная длина", () => { + const key = generatePdfKey(); + expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true); + expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32); + expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("две генерации различаются", () => { + expect(generatePdfKey()).not.toBe(generatePdfKey()); + }); +}); diff --git a/src/lib/clean-pdf/__tests__/zotero-script.test.ts b/src/lib/clean-pdf/__tests__/zotero-script.test.ts new file mode 100644 index 0000000..22ca45f --- /dev/null +++ b/src/lib/clean-pdf/__tests__/zotero-script.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script"; + +describe("buildZoteroScript", () => { + it("подставляет ключ и адрес школы", () => { + const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" }); + expect(s).toContain("'sbpdf_test123'"); + expect(s).toContain("'https://school.second-brain.ru'"); + expect(s).toContain("/api/pdf?url="); + expect(s).not.toContain("YOUR_KEY"); + }); +}); diff --git a/src/lib/clean-pdf/api-key.ts b/src/lib/clean-pdf/api-key.ts new file mode 100644 index 0000000..4278ce1 --- /dev/null +++ b/src/lib/clean-pdf/api-key.ts @@ -0,0 +1,7 @@ +import { randomBytes } from "node:crypto"; + +export const PDF_KEY_PREFIX = "sbpdf_"; + +export function generatePdfKey(): string { + return PDF_KEY_PREFIX + randomBytes(24).toString("base64url"); +} diff --git a/src/lib/clean-pdf/zotero-script.ts b/src/lib/clean-pdf/zotero-script.ts new file mode 100644 index 0000000..bac113b --- /dev/null +++ b/src/lib/clean-pdf/zotero-script.ts @@ -0,0 +1,80 @@ +// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу. +export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string { + return `// ── Настройки ────────────────────────────────────────────────────────────── +const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf +const SERVICE_URL = '${baseUrl}'; +const FORMAT = 'A4'; // 'A4' или 'Letter' +const THEME = 'light'; // 'light' или 'dark' +// ─────────────────────────────────────────────────────────────────────────── + +if (typeof item === "undefined" || !item) return; +(async function () { + let tempFilePath = null; + try { + function joinPath(dir, filename) { + if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename); + const separator = dir.includes("\\\\") ? "\\\\" : "/"; + return dir.replace(/[\\\\/]+$/, "") + separator + filename; + } + + let targetItem = item; + if (!targetItem.isRegularItem()) { + if (targetItem.isAttachment() || targetItem.isNote()) + targetItem = Zotero.Items.get(targetItem.parentID); + } + if (!targetItem?.isRegularItem()) { + Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero."); + return; + } + const url = targetItem.getField("url"); + if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; } + + const pw = new Zotero.ProgressWindow(); + pw.changeHeadline("Чистый PDF"); + const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title")); + pw.show(); + pw.addDescription("Генерируем PDF…"); + + const response = await Zotero.getMainWindow().fetch( + SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME, + { headers: { 'Authorization': 'Bearer ' + API_KEY } } + ); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + progress.setError(); + pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status))); + pw.startCloseTimer(8000); + return; + } + + const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer()); + if (uint8Array.length < 1000) { + progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return; + } + + const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140); + tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf"); + await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array); + await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" }); + + for (const attId of targetItem.getAttachments()) { + const att = Zotero.Items.get(attId); + const ct = att?.attachmentContentType || ""; + if (ct === "text/html" || ct === "application/zip") await att.eraseTx(); + } + + progress.setProgress(100); + const usesCount = response.headers.get('X-Uses-Count'); + const maxUses = response.headers.get('X-Max-Uses'); + const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : ""; + pw.addDescription("PDF прикреплён." + usageInfo); + pw.startCloseTimer(4000); + } catch (e) { + Zotero.alert(null, "Ошибка", e.toString()); + } finally { + if (tempFilePath) { + await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {}); + } + } +})();`; +}