404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
let Zotero, addon, items, api;
|
|
let selectedFolderId = null;
|
|
let selectedParentId = null;
|
|
|
|
function createHtmlElement(tagName) {
|
|
return document.createElementNS("http://www.w3.org/1999/xhtml", tagName);
|
|
}
|
|
|
|
function init() {
|
|
const args = window.arguments[0];
|
|
Zotero = args.Zotero;
|
|
addon = args.addon;
|
|
items = args.items;
|
|
api = args.api;
|
|
|
|
loadFolders();
|
|
}
|
|
|
|
async function loadFolders() {
|
|
const container = document.getElementById("folders-container");
|
|
try {
|
|
const folders = await api.sendEncryptedGetRequest("/api/extension/folders");
|
|
if (!folders || folders.length === 0) {
|
|
container.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); font-size: 13px;">No folders found in Research OS. Please create one on the website first.</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = "";
|
|
|
|
const treeRootUl = createHtmlElement("ul");
|
|
treeRootUl.className = "folder-tree";
|
|
container.appendChild(treeRootUl);
|
|
|
|
folders.forEach(rootFolder => {
|
|
// 1. Create root folder node
|
|
const rootNodeLi = createHtmlElement("li");
|
|
rootNodeLi.className = "folder-node";
|
|
|
|
const rootDiv = createHtmlElement("div");
|
|
rootDiv.className = "folder-item";
|
|
rootDiv.innerHTML = `<span class="folder-icon">📁</span><span>${rootFolder.name}</span>`;
|
|
rootDiv.onclick = () => selectFolder(rootFolder.id, null, rootDiv);
|
|
rootNodeLi.appendChild(rootDiv);
|
|
treeRootUl.appendChild(rootNodeLi);
|
|
|
|
const subfolders = rootFolder.children || [];
|
|
|
|
// Helper to render child folders recursively
|
|
function renderSubfolders(parentId, parentElement) {
|
|
const children = subfolders.filter(sub => {
|
|
if (parentId === null) {
|
|
return sub.parent_id === null || sub.parent_id === undefined;
|
|
}
|
|
return sub.parent_id === parentId;
|
|
});
|
|
|
|
if (children.length > 0) {
|
|
const branchUl = createHtmlElement("ul");
|
|
branchUl.className = "folder-branch";
|
|
parentElement.appendChild(branchUl);
|
|
|
|
children.forEach(sub => {
|
|
const childLi = createHtmlElement("li");
|
|
childLi.className = "folder-node";
|
|
|
|
const childDiv = createHtmlElement("div");
|
|
childDiv.className = "folder-item";
|
|
childDiv.innerHTML = `<span class="folder-icon">📂</span><span>${sub.name}</span>`;
|
|
childDiv.onclick = () => selectFolder(rootFolder.id, sub.id, childDiv);
|
|
|
|
childLi.appendChild(childDiv);
|
|
branchUl.appendChild(childLi);
|
|
|
|
// Recurse for nested folders
|
|
renderSubfolders(sub.id, childLi);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Start recursion
|
|
renderSubfolders(null, rootNodeLi);
|
|
});
|
|
} catch (err) {
|
|
Zotero.debug(err);
|
|
container.innerHTML = `<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--error); font-size: 13px; padding: 16px; text-align: center; gap: 8px;">
|
|
<span>Failed to fetch Research OS folders.</span>
|
|
<span style="font-size: 11px; color: var(--text-secondary);">${err.message}</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function cleanDoi(input) {
|
|
if (!input) return "";
|
|
let cleaned = input.trim().toLowerCase();
|
|
cleaned = cleaned.replace(/^(https?:\/\/)?(www\.)?(dx\.)?doi\.org\//i, "");
|
|
cleaned = cleaned.replace(/^doi\s*:\s*/i, "");
|
|
const doiRegex = /(10\.\d{4,9}\/[-._;()/:a-zA-Z0-9]+)/i;
|
|
const match = cleaned.match(doiRegex);
|
|
return match ? match[1] : cleaned;
|
|
}
|
|
|
|
function selectFolder(folderId, parentId, element) {
|
|
selectedFolderId = folderId;
|
|
selectedParentId = parentId;
|
|
|
|
// Clear selection styles
|
|
const items = document.querySelectorAll(".folder-item");
|
|
items.forEach(item => item.classList.remove("selected"));
|
|
|
|
// Highlight selected element
|
|
element.classList.add("selected");
|
|
|
|
// Enable Sync button
|
|
document.getElementById("btn-sync").disabled = false;
|
|
}
|
|
|
|
async function startSync() {
|
|
// Switch screens
|
|
document.getElementById("folder-screen").classList.remove("active");
|
|
document.getElementById("progress-screen").classList.add("active");
|
|
|
|
const itemsContainer = document.getElementById("items-container");
|
|
itemsContainer.innerHTML = "";
|
|
|
|
// Create progress rows for each item
|
|
items.forEach((item, index) => {
|
|
const row = createHtmlElement("div");
|
|
row.className = "sync-item-row";
|
|
row.id = `sync-row-${index}`;
|
|
|
|
// Determine if it has a PDF attachment
|
|
let hasPdf = false;
|
|
if (item.isRegularItem()) {
|
|
const attachments = item.getAttachments();
|
|
for (let attId of attachments) {
|
|
const att = Zotero.Items.get(attId);
|
|
if (att.attachmentContentType === "application/pdf" || att.getField("title").toLowerCase().endsWith(".pdf")) {
|
|
hasPdf = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<div class="sync-item-info">
|
|
<div class="sync-item-title">${escapeHtml(item.getField("title") || "Untitled")}</div>
|
|
<div class="sync-item-detail" id="sync-detail-${index}">
|
|
${hasPdf ? "📎 PDF Attachment found" : "📄 No PDF Attachment"}
|
|
</div>
|
|
</div>
|
|
<div class="sync-item-status status-pending" id="sync-status-${index}">Pending</div>
|
|
`;
|
|
itemsContainer.appendChild(row);
|
|
});
|
|
|
|
updateProgress(0, "Fetching existing papers in target location...");
|
|
|
|
let existingPapers = [];
|
|
try {
|
|
const parentParam = selectedParentId ? `?parent_id=${selectedParentId}` : "";
|
|
existingPapers = await api.sendEncryptedGetRequest(`/api/extension/folders/${selectedFolderId}/papers${parentParam}`) || [];
|
|
} catch (err) {
|
|
Zotero.debug(`Failed to fetch existing papers: ${err.message}`);
|
|
}
|
|
|
|
updateProgress(0, `Starting synchronization of ${items.length} items...`);
|
|
|
|
let successCount = 0;
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
const statusLabel = document.getElementById(`sync-status-${i}`);
|
|
const detailLabel = document.getElementById(`sync-detail-${i}`);
|
|
|
|
statusLabel.className = "sync-item-status status-syncing";
|
|
statusLabel.textContent = "Syncing";
|
|
|
|
updateProgress(Math.round((i / items.length) * 100), `Syncing: ${item.getField("title")}`);
|
|
|
|
try {
|
|
// Step 1: Gather metadata
|
|
const paperTitle = item.getField("title") || "Untitled";
|
|
let paperAbstract = item.getField("abstractNote") || "";
|
|
let paperAuthors = "";
|
|
try {
|
|
paperAuthors = item.getCreators().map(c => `${c.firstName} ${c.lastName}`.trim()).join(", ");
|
|
} catch (e) {}
|
|
let paperJournal = item.getField("publicationTitle") || item.getField("journalAbbreviation") || "";
|
|
let paperYear = "";
|
|
try {
|
|
const date = item.getField("date");
|
|
if (date) {
|
|
const match = date.match(/\d{4}/);
|
|
if (match) paperYear = match[0];
|
|
}
|
|
} catch (e) {}
|
|
let paperPublisher = "";
|
|
try {
|
|
paperPublisher = item.getField("publisher") || "";
|
|
} catch (e) {}
|
|
let paperSjr = "";
|
|
let paperQuartile = "";
|
|
|
|
// Step 2: Try to get DOI and check duplicate
|
|
let doi = "";
|
|
try {
|
|
doi = item.getField("DOI");
|
|
} catch (e) {}
|
|
if (!doi) {
|
|
try {
|
|
const extra = item.getField("extra") || "";
|
|
const match = extra.match(/DOI:\s*(\S+)/i);
|
|
if (match) doi = match[1];
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Duplicate matching logic
|
|
const cleanZoteroDoi = cleanDoi(doi);
|
|
const normalizedZoteroTitle = paperTitle.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
|
|
const duplicatePaper = existingPapers.find(existing => {
|
|
// 1. Try DOI matching
|
|
let existingDoi = "";
|
|
if (existing.metadata) {
|
|
try {
|
|
const meta = typeof existing.metadata === 'string' ? JSON.parse(existing.metadata) : existing.metadata;
|
|
existingDoi = meta.doi || "";
|
|
} catch (e) {}
|
|
}
|
|
if (cleanZoteroDoi && existingDoi) {
|
|
if (cleanZoteroDoi === cleanDoi(existingDoi)) {
|
|
return true;
|
|
}
|
|
}
|
|
// 2. Try Title matching
|
|
const normalizedExistingTitle = (existing.name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
if (normalizedZoteroTitle && normalizedExistingTitle && normalizedZoteroTitle === normalizedExistingTitle) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const isDuplicate = !!duplicatePaper;
|
|
if (isDuplicate) {
|
|
detailLabel.textContent = `Duplicate found. Merging attachment into existing paper (ID: ${duplicatePaper.id})...`;
|
|
}
|
|
|
|
let enriched = null;
|
|
// If NOT duplicate, fetch metadata
|
|
if (!isDuplicate) {
|
|
if (doi) {
|
|
detailLabel.textContent = "Extracting DOI and enriching metadata...";
|
|
try {
|
|
const enrichUrl = `/api/extension/paper?doi=${encodeURIComponent(doi)}`;
|
|
enriched = await api.sendEncryptedGetRequest(enrichUrl);
|
|
} catch (enrichErr) {
|
|
Zotero.debug(`Failed to enrich via DOI: ${enrichErr.message}`);
|
|
}
|
|
} else {
|
|
detailLabel.textContent = "No DOI found, searching by title...";
|
|
try {
|
|
const enrichUrl = `/api/extension/paper?title=${encodeURIComponent(paperTitle)}`;
|
|
enriched = await api.sendEncryptedGetRequest(enrichUrl);
|
|
} catch (enrichErr) {
|
|
Zotero.debug(`Failed to enrich via title: ${enrichErr.message}`);
|
|
}
|
|
}
|
|
|
|
if (enriched && enriched.metadata) {
|
|
if (enriched.metadata.abstract) paperAbstract = enriched.metadata.abstract;
|
|
if (enriched.metadata.journal && enriched.metadata.journal !== 'N/A') paperJournal = enriched.metadata.journal;
|
|
if (enriched.metadata.year && enriched.metadata.year !== 'N/A') paperYear = enriched.metadata.year;
|
|
if (enriched.metadata.authors && enriched.metadata.authors.length > 0) {
|
|
paperAuthors = Array.isArray(enriched.metadata.authors) ? enriched.metadata.authors.join(', ') : enriched.metadata.authors;
|
|
}
|
|
if (enriched.metadata.publisher && enriched.metadata.publisher !== 'N/A') {
|
|
paperPublisher = enriched.metadata.publisher;
|
|
} else if (enriched.ranking && enriched.ranking.publisher) {
|
|
paperPublisher = enriched.ranking.publisher;
|
|
}
|
|
if (enriched.ranking) {
|
|
paperSjr = enriched.ranking.sjr || "";
|
|
paperQuartile = enriched.ranking.quartile || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 3: Handle PDF attachments
|
|
const pdfs = [];
|
|
const attachments = item.getAttachments();
|
|
let pdfAttachment = null;
|
|
for (let attId of attachments) {
|
|
const att = Zotero.Items.get(attId);
|
|
if (att.attachmentContentType === "application/pdf" || att.getField("title").toLowerCase().endsWith(".pdf")) {
|
|
pdfAttachment = att;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (pdfAttachment) {
|
|
detailLabel.textContent = isDuplicate
|
|
? "Uploading duplicate PDF attachment..."
|
|
: "Reading local PDF copy...";
|
|
const filePath = await pdfAttachment.getFilePathAsync();
|
|
|
|
if (filePath) {
|
|
// Read local file bytes
|
|
const bytes = await IOUtils.read(filePath);
|
|
const blob = new Blob([bytes], { type: "application/pdf" });
|
|
|
|
detailLabel.textContent = "Uploading PDF to Research OS (Cloudflare R2)...";
|
|
const uploadRes = await api.uploadPdf(blob, `${paperTitle.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`);
|
|
|
|
if (uploadRes && uploadRes.url) {
|
|
pdfs.push({
|
|
id: Date.now().toString() + "_" + Math.random().toString(36).substr(2, 9),
|
|
name: isDuplicate ? `Attached PDF (${new Date().toLocaleDateString()})` : "Main Paper",
|
|
url: uploadRes.url,
|
|
type: "file",
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
detailLabel.textContent = "PDF successfully uploaded.";
|
|
}
|
|
} else {
|
|
Zotero.debug("PDF attachment file path was empty. PDF will not be uploaded.");
|
|
}
|
|
}
|
|
|
|
// Step 4: Insert/Update paper in database
|
|
detailLabel.textContent = isDuplicate
|
|
? "Updating existing paper record..."
|
|
: "Saving paper metadata to Research OS...";
|
|
|
|
const paperPayload = {
|
|
title: paperTitle,
|
|
doi: doi,
|
|
abstract: paperAbstract,
|
|
authors: paperAuthors,
|
|
journal: paperJournal,
|
|
year: paperYear,
|
|
publisher: paperPublisher,
|
|
sjr: paperSjr,
|
|
quartile: paperQuartile,
|
|
pdfs: pdfs
|
|
};
|
|
|
|
const payload = {
|
|
folderId: selectedFolderId,
|
|
parent_id: selectedParentId,
|
|
paper: paperPayload
|
|
};
|
|
|
|
if (isDuplicate) {
|
|
payload.paperId = duplicatePaper.id;
|
|
}
|
|
|
|
await api.sendEncryptedRequest("/api/extension/insert", payload);
|
|
|
|
statusLabel.className = "sync-item-status status-success";
|
|
statusLabel.textContent = "Success";
|
|
detailLabel.textContent = isDuplicate
|
|
? (pdfs.length > 0 ? "Merged new PDF successfully" : "Already matched (no new PDF)")
|
|
: (pdfs.length > 0 ? "Synced successfully with PDF" : "Synced successfully (no PDF)");
|
|
successCount++;
|
|
} catch (err) {
|
|
Zotero.debug(err);
|
|
statusLabel.className = "sync-item-status status-error";
|
|
statusLabel.textContent = "Error";
|
|
detailLabel.textContent = `Error: ${err.message}`;
|
|
appendErrorLog(item.getField("title") || "Untitled", err);
|
|
}
|
|
}
|
|
|
|
updateProgress(100, `Completed! Successfully synced ${successCount} of ${items.length} items.`);
|
|
document.getElementById("btn-close").disabled = false;
|
|
}
|
|
|
|
function appendErrorLog(title, err) {
|
|
const container = document.getElementById("error-log-container");
|
|
const logBox = document.getElementById("error-log-box");
|
|
if (container && logBox) {
|
|
container.style.display = "flex";
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
const stackInfo = err.stack ? `\nStack trace:\n${err.stack}` : "";
|
|
logBox.value += `[${timestamp}] Error syncing "${title}":\n${err.message}${stackInfo}\n\n`;
|
|
}
|
|
}
|
|
|
|
function updateProgress(percent, text) {
|
|
document.getElementById("progress-bar").style.width = `${percent}%`;
|
|
document.getElementById("progress-percent").textContent = `${percent}%`;
|
|
document.getElementById("progress-text").textContent = text;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const map = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'"
|
|
};
|
|
return text.replace(/[&<>"']/g, m => map[m]);
|
|
}
|