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 = `
No folders found in Research OS. Please create one on the website first.
`;
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 = `📁${rootFolder.name}`;
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 = `📂${sub.name}`;
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 = `
Failed to fetch Research OS folders.
${err.message}
`;
}
}
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 = `
${escapeHtml(item.getField("title") || "Untitled")}
${hasPdf ? "📎 PDF Attachment found" : "📄 No PDF Attachment"}
Pending
`;
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]);
}