-
-
Save minimarimo3/c341778865e0561af33feb2e8090d795 to your computer and use it in GitHub Desktop.
| { | |
| "translatorID": "b8a08333-55b2-4b12-b75d-80457b8bcff6", | |
| "label": "Hayagriva", | |
| "creator": "minimarimo3 + ChatGPT", | |
| "target": "yaml", | |
| "minVersion": "5.0", | |
| "maxVersion": "", | |
| "priority": 100, | |
| "inRepository": false, | |
| "translatorType": 2, | |
| "lastUpdated": "2026-07-14 10:54:02" | |
| } | |
| // Hayagriva export translator for Zotero | |
| // | |
| // User-configurable citation key options. Reapply custom settings after | |
| // replacing this translator with a newer version. | |
| // | |
| // Supported pattern tokens: | |
| // - {author}: primary creator, publisher, institution, organization, etc. | |
| // - {year}: four-digit year when available | |
| // - {title}: full title, or "untitled" when missing | |
| // - {short-title}: first shortTitleWords words from the normalized title | |
| // - {zotero-key}: Zotero item key when available | |
| // - {item-type}: Zotero item type | |
| // | |
| // Set useStoredCitationKey to true to prefer Zotero's stored Citation Key | |
| // (including keys managed by Better BibTeX). Stored keys are preserved without | |
| // applying pattern, slugification, lowercase conversion, or maxLength. If a | |
| // stored key is unavailable, the configured pattern is used as a fallback. | |
| // | |
| // Set maxLength to null for no length limit on generated keys. Numeric values | |
| // below 8 are raised to 8 so that duplicate suffixes can be represented safely. | |
| var CITATION_KEY_OPTIONS = { | |
| useStoredCitationKey: false, | |
| pattern: "{author}_{year}_{title}", | |
| maxLength: 40, | |
| duplicateSuffix: "_{n}", | |
| shortTitleWords: 3 | |
| }; | |
| var DEFAULT_CITATION_KEY_PATTERN = "{author}_{year}_{title}"; | |
| var DEFAULT_CITATION_KEY_MAX_LENGTH = 40; | |
| var DEFAULT_DUPLICATE_SUFFIX = "_{n}"; | |
| var MIN_CITATION_KEY_LENGTH = 8; | |
| // References: | |
| // - Zotero translator docs: https://www.zotero.org/support/dev/translators | |
| // - Hayagriva file format: https://github.com/typst/hayagriva/blob/main/docs/file-format.md | |
| function doExport() { | |
| var seenKeys = Object.create(null); | |
| var item; | |
| while ((item = Zotero.nextItem())) { | |
| if (!item || item.itemType === "note" || item.itemType === "attachment") continue; | |
| var entry = buildEntry(item); | |
| var keyData = getCitationKeyData(item); | |
| var key = makeUniqueCitationKey( | |
| keyData.base, | |
| seenKeys, | |
| keyData.maxLength, | |
| keyData.isStored | |
| ); | |
| Zotero.write(yamlMappingKey(key) + ":\n"); | |
| Zotero.write(serializeMapping(entry, 2)); | |
| Zotero.write("\n"); | |
| } | |
| } | |
| function buildEntry(item) { | |
| var entryType = getHayagrivaType(item); | |
| var parent = buildParent(item, entryType); | |
| var creatorData = buildCreators(item, entryType, parent); | |
| var identifiers = collectSerialNumbers(item); | |
| var entry = {}; | |
| entry.type = entryType; | |
| assign(entry, "title", cleanText(item.title)); | |
| assign(entry, "author", creatorData.author); | |
| assign(entry, "date", normalizeDate(item.date)); | |
| assign(entry, "editor", creatorData.editor); | |
| assign(entry, "affiliated", creatorData.affiliated); | |
| assign(entry, "publisher", buildTopLevelPublisher(item, entryType, !!parent)); | |
| assign(entry, "organization", buildOrganization(item, entryType)); | |
| assign(entry, "location", buildTopLevelLocation(item, entryType, !!parent)); | |
| assign(entry, "serial-number", identifiers); | |
| assign(entry, "language", cleanValue(item.language)); | |
| assign(entry, "edition", shouldKeepTopLevelNumbering(entryType, parent) ? toNumberIfPossible(item.edition) : null); | |
| assign(entry, "genre", buildGenre(item, entryType)); | |
| assign(entry, "issue", shouldKeepTopLevelNumbering(entryType, parent) ? toNumberIfPossible(item.issue) : null); | |
| assign(entry, "volume", shouldKeepTopLevelNumbering(entryType, parent) ? toNumberIfPossible(item.volume) : null); | |
| assign(entry, "volume-total", shouldKeepTopLevelNumbering(entryType, parent) ? toNumberIfPossible(item.numberOfVolumes) : null); | |
| assign(entry, "chapter", shouldKeepTopLevelNumbering(entryType, parent) ? toNumberIfPossible(item.section) : null); | |
| assign(entry, "page-range", normalizePageRange(item.pages)); | |
| assign(entry, "page-total", toNumberIfPossible(item.numPages)); | |
| assign(entry, "url", buildUrl(item.url, item.accessDate)); | |
| assign(entry, "archive", cleanValue(item.archive)); | |
| assign(entry, "archive-location", cleanValue(item.archiveLocation)); | |
| assign(entry, "call-number", cleanValue(item.callNumber)); | |
| assign(entry, "runtime", cleanValue(item.runningTime)); | |
| assign(entry, "abstract", cleanText(item.abstractNote)); | |
| assign(entry, "note", buildNote(item)); | |
| assign(entry, "parent", parent); | |
| return entry; | |
| } | |
| function buildCreators(item, entryType, parent) { | |
| var authors = []; | |
| var editors = []; | |
| var affiliatedByRole = Object.create(null); | |
| var parentEditors = []; | |
| var creators = item.creators || []; | |
| for (var i = 0; i < creators.length; i++) { | |
| var creator = creators[i]; | |
| var name = formatCreator(creator); | |
| if (!name) continue; | |
| if (creator.creatorType === "author") { | |
| authors.push(name); | |
| continue; | |
| } | |
| if (creator.creatorType === "editor") { | |
| if (shouldAttachEditorsToParent(item, entryType, parent)) { | |
| parentEditors.push(name); | |
| } | |
| else { | |
| editors.push(name); | |
| } | |
| continue; | |
| } | |
| if (creator.creatorType === "bookAuthor") { | |
| if (!parent) parent = {}; | |
| if (!parent.author) parent.author = []; | |
| parent.author.push(name); | |
| continue; | |
| } | |
| if (creator.creatorType === "seriesEditor") { | |
| if (parent) { | |
| if (!parent.editor) parent.editor = []; | |
| parent.editor.push(name); | |
| } | |
| else { | |
| editors.push(name); | |
| } | |
| continue; | |
| } | |
| // Reasonable fallbacks for creator-heavy item types | |
| if (item.itemType === "interview" && creator.creatorType === "interviewee") { | |
| authors.push(name); | |
| continue; | |
| } | |
| if ((item.itemType === "presentation" || item.itemType === "podcast") && creator.creatorType === "presenter") { | |
| authors.push(name); | |
| continue; | |
| } | |
| var role = mapRole(creator.creatorType); | |
| if (!role) continue; | |
| if (!affiliatedByRole[role]) affiliatedByRole[role] = []; | |
| affiliatedByRole[role].push(name); | |
| } | |
| if (parent && parentEditors.length) { | |
| if (!parent.editor) parent.editor = []; | |
| parent.editor = parent.editor.concat(parentEditors); | |
| } | |
| var affiliated = []; | |
| for (var role in affiliatedByRole) { | |
| affiliated.push({ | |
| role: role, | |
| names: affiliatedByRole[role].length === 1 ? affiliatedByRole[role][0] : affiliatedByRole[role] | |
| }); | |
| } | |
| return { | |
| author: authors.length ? authors : null, | |
| editor: editors.length ? editors : null, | |
| affiliated: affiliated.length ? affiliated : null | |
| }; | |
| } | |
| function shouldAttachEditorsToParent(item, entryType, parent) { | |
| if (!parent) return false; | |
| if (["article", "chapter", "entry", "anthos"].indexOf(entryType) !== -1) return true; | |
| if (item.itemType === "conferencePaper") return true; | |
| return false; | |
| } | |
| function buildParent(item, entryType) { | |
| var parent = {}; | |
| if (["journalArticle", "magazineArticle", "newspaperArticle"].indexOf(item.itemType) !== -1) { | |
| parent.type = item.itemType === "newspaperArticle" ? "newspaper" : "periodical"; | |
| assign(parent, "title", cleanText(item.publicationTitle)); | |
| assign(parent, "volume", toNumberIfPossible(item.volume)); | |
| assign(parent, "issue", toNumberIfPossible(item.issue)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "bookSection") { | |
| parent.type = entryType === "anthos" ? "anthology" : "book"; | |
| assign(parent, "title", cleanText(item.bookTitle || item.publicationTitle)); | |
| assign(parent, "volume", toNumberIfPossible(item.volume)); | |
| assign(parent, "volume-total", toNumberIfPossible(item.numberOfVolumes)); | |
| assign(parent, "edition", cleanValue(item.edition)); | |
| assign(parent, "publisher", buildPublisher(item.publisher, item.place)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "encyclopediaArticle" || item.itemType === "dictionaryEntry") { | |
| parent.type = "reference"; | |
| assign(parent, "title", cleanText(item.encyclopediaTitle || item.dictionaryTitle || item.publicationTitle)); | |
| assign(parent, "volume", toNumberIfPossible(item.volume)); | |
| assign(parent, "edition", cleanValue(item.edition)); | |
| assign(parent, "publisher", buildPublisher(item.publisher, item.place)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "conferencePaper") { | |
| parent.type = item.publicationTitle ? "proceedings" : "conference"; | |
| assign(parent, "title", cleanText(item.publicationTitle || item.conferenceName)); | |
| assign(parent, "organization", cleanValue(item.proceedingsTitle ? null : item.conferenceName && !item.publicationTitle ? item.publisher : null)); | |
| assign(parent, "publisher", buildPublisher(item.publisher, item.place)); | |
| assign(parent, "volume", toNumberIfPossible(item.volume)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "blogPost") { | |
| parent.type = "blog"; | |
| assign(parent, "title", cleanText(item.blogTitle || item.websiteTitle || item.publicationTitle)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "webpage") { | |
| parent.type = "web"; | |
| assign(parent, "title", cleanText(item.websiteTitle || item.publicationTitle)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| if (item.itemType === "forumPost") { | |
| parent.type = "thread"; | |
| assign(parent, "title", cleanText(item.forumTitle || item.websiteTitle || item.publicationTitle)); | |
| assign(parent, "url", buildUrl(item.url, item.accessDate)); | |
| return hasContent(parent) ? parent : null; | |
| } | |
| return null; | |
| } | |
| function getHayagrivaType(item) { | |
| if (item.itemType === "bookSection") { | |
| var hasBookAuthor = hasCreatorType(item, "bookAuthor"); | |
| var hasEditor = hasCreatorType(item, "editor") || hasCreatorType(item, "seriesEditor"); | |
| return hasEditor && !hasBookAuthor ? "anthos" : "chapter"; | |
| } | |
| var map = { | |
| book: "book", | |
| journalArticle: "article", | |
| magazineArticle: "article", | |
| newspaperArticle: "article", | |
| thesis: "thesis", | |
| letter: "misc", | |
| manuscript: "manuscript", | |
| interview: "misc", | |
| film: "video", | |
| artwork: "artwork", | |
| webpage: "web", | |
| conferencePaper: "article", | |
| report: "report", | |
| bill: "legislation", | |
| case: "case", | |
| hearing: "misc", | |
| patent: "patent", | |
| statute: "legislation", | |
| email: "misc", | |
| map: "misc", | |
| blogPost: "article", | |
| instantMessage: "misc", | |
| forumPost: "thread", | |
| audioRecording: "audio", | |
| podcast: "audio", | |
| presentation: "misc", | |
| videoRecording: "video", | |
| tvBroadcast: "video", | |
| radioBroadcast: "audio", | |
| computerProgram: "repository", | |
| document: "misc", | |
| encyclopediaArticle: "entry", | |
| dictionaryEntry: "entry", | |
| dataset: "misc", | |
| preprint: "article", | |
| standard: "report" | |
| }; | |
| return map[item.itemType] || "misc"; | |
| } | |
| function buildTopLevelPublisher(item, entryType, hasParent) { | |
| if (hasParent) { | |
| if (["book", "report", "thesis", "manuscript", "patent", "legislation", "case", "misc", "repository", "artwork", "audio", "video", "web"].indexOf(entryType) === -1) { | |
| return null; | |
| } | |
| } | |
| if (entryType === "report" && !item.publisher && item.institution) return null; | |
| if (entryType === "thesis" && !item.publisher && item.university) return null; | |
| return buildPublisher(item.publisher, needsPublisherLocation(entryType, hasParent) ? item.place : null); | |
| } | |
| function needsPublisherLocation(entryType, hasParent) { | |
| if (hasParent && ["article", "chapter", "entry", "anthos"].indexOf(entryType) !== -1) return false; | |
| return ["book", "report", "thesis", "manuscript", "patent", "legislation", "case", "repository", "misc"].indexOf(entryType) !== -1; | |
| } | |
| function buildOrganization(item, entryType) { | |
| if (entryType === "thesis") return cleanValue(item.university || item.institution); | |
| if (entryType === "report") return cleanValue(item.institution || item.organization); | |
| if (entryType === "repository") return cleanValue(item.company || item.organization || item.publisher); | |
| if (entryType === "patent") return cleanValue(item.assignee); | |
| return cleanValue(item.organization); | |
| } | |
| function buildTopLevelLocation(item, entryType, hasParent) { | |
| if (!item.place) return null; | |
| if (needsPublisherLocation(entryType, hasParent)) return null; | |
| if (["video", "audio", "artwork", "misc", "performance"].indexOf(entryType) !== -1) return cleanValue(item.place); | |
| return null; | |
| } | |
| function buildGenre(item, entryType) { | |
| if (entryType === "thesis") return cleanValue(item.thesisType || "thesis"); | |
| if (entryType === "report") return cleanValue(item.reportType); | |
| if (item.itemType === "webpage") return cleanValue(item.websiteType); | |
| if (item.itemType === "artwork") return cleanValue(item.artworkMedium || item.type); | |
| if (item.itemType === "interview") return "interview"; | |
| if (item.itemType === "letter") return "letter"; | |
| if (item.itemType === "presentation") return cleanValue(item.presentationType || "presentation"); | |
| if (item.itemType === "standard") return "standard"; | |
| return null; | |
| } | |
| function buildNote(item) { | |
| var parts = []; | |
| var extraNote = stripStructuredExtra(item.extra); | |
| if (extraNote) parts.push(cleanText(extraNote)); | |
| if (item.medium && item.itemType !== "artwork") parts.push("Medium: " + cleanText(item.medium)); | |
| if (item.libraryCatalog) parts.push("Catalog: " + cleanText(item.libraryCatalog)); | |
| var note = parts.filter(Boolean).join("\n\n"); | |
| return note || null; | |
| } | |
| function shouldKeepTopLevelNumbering(entryType, parent) { | |
| if (!parent) return true; | |
| return ["book", "report", "thesis", "manuscript", "case", "legislation", "patent", "repository", "misc", "artwork", "audio", "video", "web"].indexOf(entryType) !== -1; | |
| } | |
| function stripStructuredExtra(extra) { | |
| if (!extra) return null; | |
| var lines = String(extra).split(/\r?\n/); | |
| var kept = []; | |
| for (var i = 0; i < lines.length; i++) { | |
| var line = lines[i].trim(); | |
| if (!line) { | |
| kept.push(lines[i]); | |
| continue; | |
| } | |
| if (/^(DOI|ISBN|ISSN|PMID|PMCID|arXiv|LCCN|MR|Zbl|Version|Version Number|Report Number|Patent Number|Docket Number)\s*:/i.test(line)) continue; | |
| kept.push(lines[i]); | |
| } | |
| var joined = kept.join("\n").trim(); | |
| return joined || null; | |
| } | |
| function buildPublisher(name, location) { | |
| name = cleanValue(name); | |
| location = cleanValue(location); | |
| if (!name) return null; | |
| if (location) return { name: name, location: location }; | |
| return name; | |
| } | |
| function buildUrl(url, accessDate) { | |
| url = cleanValue(url); | |
| if (!url) return null; | |
| var date = normalizeDate(accessDate); | |
| if (date) { | |
| return { | |
| value: url, | |
| date: date | |
| }; | |
| } | |
| return url; | |
| } | |
| function collectSerialNumbers(item) { | |
| var serials = {}; | |
| assign(serials, "doi", cleanValue(item.DOI)); | |
| assign(serials, "isbn", cleanValue(item.ISBN)); | |
| assign(serials, "issn", cleanValue(item.ISSN)); | |
| assign(serials, "pmid", cleanValue(item.PMID)); | |
| assign(serials, "pmcid", cleanValue(item.PMCID)); | |
| if (item.itemType === "report") assign(serials, "serial", cleanValue(item.reportNumber)); | |
| if (item.itemType === "patent") assign(serials, "serial", cleanValue(item.patentNumber)); | |
| if (item.itemType === "computerProgram") assign(serials, "version", cleanValue(item.versionNumber || item.version)); | |
| if (item.itemType === "case") assign(serials, "serial", cleanValue(item.docketNumber)); | |
| var extraFields = parseExtraFields(item.extra); | |
| mergePreferExisting(serials, extraFields); | |
| return hasContent(serials) ? serials : null; | |
| } | |
| function parseExtraFields(extra) { | |
| var serials = {}; | |
| if (!extra) return serials; | |
| var lines = String(extra).split(/\r?\n/); | |
| for (var i = 0; i < lines.length; i++) { | |
| var line = lines[i].trim(); | |
| if (!line) continue; | |
| var m = line.match(/^(DOI|ISBN|ISSN|PMID|PMCID|arXiv|LCCN|MR|Zbl|Version|Version Number|Report Number|Patent Number|Docket Number)\s*:\s*(.+)$/i); | |
| if (!m) continue; | |
| var label = m[1].toLowerCase(); | |
| var value = cleanValue(m[2]); | |
| if (!value) continue; | |
| if (label === "version" || label === "version number") serials.version = value; | |
| else if (label === "report number" || label === "patent number" || label === "docket number") serials.serial = value; | |
| else serials[label] = value; | |
| } | |
| return serials; | |
| } | |
| function mapRole(creatorType) { | |
| var map = { | |
| translator: "translator", | |
| contributor: "collaborator", | |
| interviewee: "collaborator", | |
| interviewer: "collaborator", | |
| director: "director", | |
| scriptwriter: "writer", | |
| producer: "producer", | |
| castMember: "cast-member", | |
| sponsor: "organizer", | |
| counsel: "collaborator", | |
| inventor: "holder", | |
| artist: "illustrator", | |
| performer: "cast-member", | |
| composer: "composer", | |
| wordsBy: "writer", | |
| cartographer: "illustrator", | |
| programmer: "writer", | |
| podcaster: "executive-producer", | |
| presenter: "cast-member", | |
| guest: "collaborator", | |
| host: "collaborator" | |
| }; | |
| return map[creatorType] || "collaborator"; | |
| } | |
| function formatCreator(creator) { | |
| if (!creator) return null; | |
| if (creator.fieldMode === 1 && creator.lastName) return cleanValue(creator.lastName); | |
| if (creator.name) return cleanValue(creator.name); | |
| var last = cleanValue(creator.lastName); | |
| var first = cleanValue(creator.firstName); | |
| if (last && first) return last + ", " + first; | |
| return last || first || null; | |
| } | |
| function getCitationKeyData(item) { | |
| if (CITATION_KEY_OPTIONS.useStoredCitationKey) { | |
| var storedKey = cleanValue(item.citationKey); | |
| if (storedKey) { | |
| return { | |
| base: storedKey, | |
| maxLength: null, | |
| isStored: true | |
| }; | |
| } | |
| } | |
| return { | |
| base: generateCitationKey(item), | |
| maxLength: getCitationKeyMaxLength(), | |
| isStored: false | |
| }; | |
| } | |
| function generateCitationKey(item) { | |
| var pattern = cleanValue(CITATION_KEY_OPTIONS.pattern) || DEFAULT_CITATION_KEY_PATTERN; | |
| // Preserve the historical author_year_full_title behavior exactly when the | |
| // default pattern is selected. Length limiting is applied later, after a | |
| // duplicate suffix has been chosen. | |
| if (pattern === DEFAULT_CITATION_KEY_PATTERN) { | |
| return generateLegacyCitationKeyBase(item); | |
| } | |
| var title = cleanText(item.title) || "untitled"; | |
| var tokenValues = { | |
| author: getPrimaryKeyPart(item), | |
| year: extractYear(item.date), | |
| title: title, | |
| "short-title": getShortTitle(title, CITATION_KEY_OPTIONS.shortTitleWords), | |
| "zotero-key": cleanValue(item.key), | |
| "item-type": cleanValue(item.itemType) | |
| }; | |
| var unknownTokens = Object.create(null); | |
| var rendered = pattern.replace(/\{([^{}]+)\}/g, function (match, token) { | |
| if (Object.prototype.hasOwnProperty.call(tokenValues, token)) { | |
| return slugify(tokenValues[token] || ""); | |
| } | |
| unknownTokens[token] = true; | |
| return ""; | |
| }); | |
| for (var token in unknownTokens) { | |
| debugCitationKey("Unknown citation key token {" + token + "}; it was omitted."); | |
| } | |
| var key = normalizePatternSeparators(slugify(rendered)); | |
| return key || "item"; | |
| } | |
| function generateLegacyCitationKeyBase(item) { | |
| var parts = []; | |
| var authorish = getPrimaryKeyPart(item); | |
| var year = extractYear(item.date); | |
| var title = slugify(cleanText(item.title) || "untitled"); | |
| if (authorish) parts.push(slugify(authorish)); | |
| if (year) parts.push(year); | |
| if (title) parts.push(title); | |
| return parts.filter(Boolean).join("_") || "item"; | |
| } | |
| function getShortTitle(title, wordLimit) { | |
| var normalized = slugify(title || ""); | |
| if (!normalized) return ""; | |
| var limit = parseInt(wordLimit, 10); | |
| if (!isFinite(limit) || limit < 1) limit = 3; | |
| var words = normalized.split(/[_-]+/).filter(Boolean); | |
| if (!words.length) return normalized; | |
| return words.slice(0, limit).join("_"); | |
| } | |
| function normalizePatternSeparators(value) { | |
| value = value || ""; | |
| value = value.replace(/[_-]{2,}/g, function (separators) { | |
| return separators.charAt(0); | |
| }); | |
| value = value.replace(/^[_-]+|[_-]+$/g, ""); | |
| return value; | |
| } | |
| function getPrimaryKeyPart(item) { | |
| if (item.creators && item.creators.length) { | |
| for (var i = 0; i < item.creators.length; i++) { | |
| var creator = item.creators[i]; | |
| if (creator.creatorType === "author" || creator.creatorType === "inventor" || creator.creatorType === "director" || creator.creatorType === "presenter") { | |
| return creator.lastName || creator.name || creator.firstName; | |
| } | |
| } | |
| var first = item.creators[0]; | |
| if (first) return first.lastName || first.name || first.firstName; | |
| } | |
| return item.publisher || item.institution || item.organization || item.university || item.websiteTitle || domainFromUrl(item.url) || "item"; | |
| } | |
| function extractYear(date) { | |
| var iso = normalizeDate(date) || cleanValue(date); | |
| if (!iso) return null; | |
| var m = String(iso).match(/^-?\d{4}/); | |
| return m ? m[0] : null; | |
| } | |
| function makeUniqueCitationKey(base, usedKeys, maxLength, isStored) { | |
| base = base || "item"; | |
| if (maxLength === undefined) maxLength = getCitationKeyMaxLength(); | |
| var candidate = fitCitationKey(base, "", maxLength); | |
| var number = 2; | |
| if (usedKeys[candidate] && isStored) { | |
| debugCitationKey( | |
| 'Duplicate stored Citation Key "' + base + | |
| '"; an export-only suffix will be added.' | |
| ); | |
| } | |
| while (usedKeys[candidate]) { | |
| var suffix = makeDuplicateSuffix(number, maxLength); | |
| candidate = fitCitationKey(base, suffix, maxLength); | |
| number++; | |
| } | |
| // Reserve the actual emitted key, not just the untrimmed base. This avoids | |
| // collisions such as foo, foo, foo_2 and collisions caused by truncation. | |
| usedKeys[candidate] = true; | |
| return candidate; | |
| } | |
| function getCitationKeyMaxLength() { | |
| var value = CITATION_KEY_OPTIONS.maxLength; | |
| if (value === null) return null; | |
| if (value === undefined) return DEFAULT_CITATION_KEY_MAX_LENGTH; | |
| var parsed = parseInt(value, 10); | |
| if (!isFinite(parsed)) { | |
| debugCitationKey("Invalid maxLength; using " + DEFAULT_CITATION_KEY_MAX_LENGTH + "."); | |
| return DEFAULT_CITATION_KEY_MAX_LENGTH; | |
| } | |
| if (parsed < MIN_CITATION_KEY_LENGTH) { | |
| debugCitationKey("maxLength below " + MIN_CITATION_KEY_LENGTH + "; using " + MIN_CITATION_KEY_LENGTH + "."); | |
| return MIN_CITATION_KEY_LENGTH; | |
| } | |
| return parsed; | |
| } | |
| function makeDuplicateSuffix(number, maxLength) { | |
| var pattern = cleanValue(CITATION_KEY_OPTIONS.duplicateSuffix) || DEFAULT_DUPLICATE_SUFFIX; | |
| if (pattern.indexOf("{n}") === -1) { | |
| debugCitationKey("duplicateSuffix must contain {n}; using " + DEFAULT_DUPLICATE_SUFFIX + "."); | |
| pattern = DEFAULT_DUPLICATE_SUFFIX; | |
| } | |
| var suffix = sanitizeCitationKeySuffix(pattern.replace(/\{n\}/g, String(number))); | |
| if (!suffix) suffix = "_" + number; | |
| if (maxLength !== null && codePointLength(suffix) >= maxLength) { | |
| debugCitationKey("duplicateSuffix is too long for maxLength; using _{n} for this key."); | |
| suffix = "_" + number; | |
| } | |
| return suffix; | |
| } | |
| function sanitizeCitationKeySuffix(value) { | |
| value = cleanText(value || "") || ""; | |
| value = value.replace(/[^\p{L}\p{N}_-]+/gu, "_"); | |
| value = value.replace(/_+$/g, ""); | |
| return value.toLowerCase(); | |
| } | |
| function fitCitationKey(base, suffix, maxLength) { | |
| base = base || "item"; | |
| suffix = suffix || ""; | |
| if (maxLength === null) return base + suffix; | |
| var available = maxLength - codePointLength(suffix); | |
| if (available < 1) available = 1; | |
| var trimmed = sliceCodePoints(base, 0, available).replace(/_+$/g, ""); | |
| if (!trimmed) { | |
| trimmed = sliceCodePoints("item", 0, available) || "i"; | |
| } | |
| return trimmed + suffix; | |
| } | |
| function codePointLength(value) { | |
| return toCodePoints(String(value || "")).length; | |
| } | |
| function sliceCodePoints(value, start, end) { | |
| return toCodePoints(String(value || "")).slice(start, end).join(""); | |
| } | |
| function toCodePoints(value) { | |
| var points = []; | |
| for (var i = 0; i < value.length; i++) { | |
| var first = value.charCodeAt(i); | |
| if (first >= 0xD800 && first <= 0xDBFF && i + 1 < value.length) { | |
| var second = value.charCodeAt(i + 1); | |
| if (second >= 0xDC00 && second <= 0xDFFF) { | |
| points.push(value.slice(i, i + 2)); | |
| i++; | |
| continue; | |
| } | |
| } | |
| points.push(value.charAt(i)); | |
| } | |
| return points; | |
| } | |
| function debugCitationKey(message) { | |
| if (typeof Zotero !== "undefined" && Zotero.debug) { | |
| Zotero.debug("Hayagriva citation key: " + message); | |
| } | |
| } | |
| function slugify(str) { | |
| str = cleanText(str || ""); | |
| if (!str) return ""; | |
| str = str.replace(/[^\p{L}\p{N}_-]+/gu, "_"); | |
| str = str.replace(/^_+|_+$/g, ""); | |
| return str.toLowerCase(); | |
| } | |
| function normalizeDate(value) { | |
| if (!value) return null; | |
| var iso = Zotero.Utilities.strToISO(value); | |
| return iso || cleanValue(value); | |
| } | |
| function normalizePageRange(value) { | |
| value = cleanValue(value); | |
| if (!value) return null; | |
| return value.replace(/--+/g, "-"); | |
| } | |
| function toNumberIfPossible(value) { | |
| if (value === undefined || value === null || value === "") return null; | |
| if (typeof value === "number") return value; | |
| var str = String(value).trim(); | |
| if (/^-?\d+$/.test(str)) return parseInt(str, 10); | |
| return str; | |
| } | |
| function cleanText(str) { | |
| if (str === undefined || str === null || str === "") return null; | |
| str = String(str); | |
| str = str.replace(/<\s*br\s*\/?\s*>/gi, "\n"); | |
| str = str.replace(/<\/p\s*>/gi, "\n"); | |
| str = str.replace(/<[^>]+>/g, ""); | |
| str = str.replace(/\u00A0/g, " "); | |
| str = str.replace(/[ \t\f\v]+/g, " "); | |
| str = str.replace(/\n\s+/g, "\n"); | |
| str = str.replace(/\n{3,}/g, "\n\n"); | |
| str = str.trim(); | |
| return str || null; | |
| } | |
| function cleanValue(value) { | |
| if (value === undefined || value === null || value === "") return null; | |
| value = String(value).trim(); | |
| return value || null; | |
| } | |
| function domainFromUrl(url) { | |
| url = cleanValue(url); | |
| if (!url) return null; | |
| var m = url.match(/^https?:\/\/([^\/]+)/i); | |
| if (!m) return null; | |
| return m[1].replace(/^www\./i, ""); | |
| } | |
| function hasCreatorType(item, creatorType) { | |
| var creators = item.creators || []; | |
| for (var i = 0; i < creators.length; i++) { | |
| if (creators[i].creatorType === creatorType) return true; | |
| } | |
| return false; | |
| } | |
| function assign(obj, key, value) { | |
| if (value === undefined || value === null) return; | |
| if (typeof value === "string" && value === "") return; | |
| if (Array.isArray(value) && !value.length) return; | |
| if (isPlainObject(value) && !hasContent(value)) return; | |
| obj[key] = value; | |
| } | |
| function mergePreferExisting(target, source) { | |
| for (var key in source) { | |
| if (target[key] === undefined || target[key] === null || target[key] === "") { | |
| target[key] = source[key]; | |
| } | |
| } | |
| } | |
| function hasContent(value) { | |
| if (value === undefined || value === null) return false; | |
| if (typeof value === "string") return value !== ""; | |
| if (Array.isArray(value)) return value.length > 0; | |
| if (isPlainObject(value)) { | |
| for (var key in value) { | |
| if (hasContent(value[key])) return true; | |
| } | |
| return false; | |
| } | |
| return true; | |
| } | |
| function isPlainObject(value) { | |
| return Object.prototype.toString.call(value) === "[object Object]"; | |
| } | |
| function serializeMapping(obj, indent) { | |
| var lines = []; | |
| for (var key in obj) { | |
| serializeField(lines, key, obj[key], indent); | |
| } | |
| return lines.join(""); | |
| } | |
| function serializeField(lines, key, value, indent) { | |
| if (!hasContent(value)) return; | |
| var pad = repeat(" ", indent); | |
| if (Array.isArray(value)) { | |
| lines.push(pad + key + ":\n"); | |
| for (var i = 0; i < value.length; i++) { | |
| serializeListItem(lines, value[i], indent + 2); | |
| } | |
| return; | |
| } | |
| if (isPlainObject(value)) { | |
| lines.push(pad + key + ":\n"); | |
| for (var subKey in value) { | |
| serializeField(lines, subKey, value[subKey], indent + 2); | |
| } | |
| return; | |
| } | |
| lines.push(pad + key + ": " + yamlScalar(value, key) + "\n"); | |
| } | |
| function serializeListItem(lines, value, indent) { | |
| var pad = repeat(" ", indent); | |
| if (Array.isArray(value)) { | |
| lines.push(pad + "-\n"); | |
| for (var i = 0; i < value.length; i++) { | |
| serializeListItem(lines, value[i], indent + 2); | |
| } | |
| return; | |
| } | |
| if (isPlainObject(value)) { | |
| var keys = []; | |
| for (var key in value) { | |
| if (hasContent(value[key])) keys.push(key); | |
| } | |
| if (!keys.length) return; | |
| var firstKey = keys[0]; | |
| var firstValue = value[firstKey]; | |
| if (!Array.isArray(firstValue) && !isPlainObject(firstValue)) { | |
| lines.push(pad + "- " + firstKey + ": " + yamlScalar(firstValue, firstKey) + "\n"); | |
| for (var i = 1; i < keys.length; i++) { | |
| serializeField(lines, keys[i], value[keys[i]], indent + 2); | |
| } | |
| } | |
| else { | |
| lines.push(pad + "-\n"); | |
| for (var j = 0; j < keys.length; j++) { | |
| serializeField(lines, keys[j], value[keys[j]], indent + 2); | |
| } | |
| } | |
| return; | |
| } | |
| lines.push(pad + "- " + yamlScalar(value, null) + "\n"); | |
| } | |
| function yamlMappingKey(value) { | |
| var str = String(value); | |
| // Generated keys use this character set and can retain the historical plain | |
| // YAML style. Stored Citation Keys outside this conservative set are quoted | |
| // so their exact value remains valid as a YAML mapping key. | |
| if (/^[\p{L}\p{N}_-]+$/u.test(str) && str !== "---" && str !== "...") { | |
| return str; | |
| } | |
| return yamlQuotedString(str); | |
| } | |
| function yamlScalar(value, key) { | |
| if (typeof value === "number") return String(value); | |
| if (typeof value === "boolean") return value ? "true" : "false"; | |
| return yamlString(value, key); | |
| } | |
| function yamlString(value, key) { | |
| var str = String(value); | |
| // Emit a few Hayagriva-friendly scalars in plain style so the output looks | |
| // closer to the CLI while keeping serial numbers like DOI as strings. | |
| if (key === "date" && /^-?\d{4}(?:-\d{2}){0,2}$/.test(str)) return str; | |
| if ((key === "type" || key === "role") && /^[A-Za-z][A-Za-z-]*$/.test(str)) return str; | |
| if (key === "language" && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(str)) return str; | |
| if (key === "page-range" && /^\d+(?:-\d+)+$/.test(str)) return str; | |
| if (key === "value" && /^https?:\/\/\S+$/.test(str)) return str; | |
| return yamlQuotedString(str); | |
| } | |
| function yamlQuotedString(value) { | |
| return '"' + String(value) | |
| .replace(/\\/g, "\\\\") | |
| .replace(/\r/g, "\\r") | |
| .replace(/\n/g, "\\n") | |
| .replace(/\t/g, "\\t") | |
| .replace(/"/g, '\\"') + '"'; | |
| } | |
| function repeat(str, times) { | |
| var out = ""; | |
| for (var i = 0; i < times; i++) out += str; | |
| return out; | |
| } |
Really appreciate your creating this – would be great to export from zotero straight to Hayagriva YAML.
I've just tried to use this with the latest version of Zotero on macOS. It exports a clean YAML file, but that file is missing all the publisher and place information. Same Zotero collection exported to BibLaTeX includes that information, which is in zotero.
Thanks for letting me know! To be honest, I had completely forgotten about this script.
I just made some updates, and the publisher and place information should be exported correctly now.
(If it still doesn't work properly, could you please send me the .bib file for reference?)
Good luck with your research! ヾ(^-^)ノ
Yep, that seems to be working great. Thank you!
FWIW, the hayagriva cli also seems to convert from .bib just fine:
hayagriva literature.bib > converted.yaml
But your plugin allows users to skip a step by just going straight from zotero to hayagriva.
In some cases lines 492-494:
if (trimmed.length + suffix.length > 40) {
trimmed = trimmed.slice(0, 40 - suffix.length).replace(/_+$/g, "");
}
generate duplicate keys, so I had to comment them in the code.
Thanks for letting me know!
You were right—the 40-character truncation could generate duplicate citation keys in some cases.
I've updated the script so that it now checks the final generated key properly before exporting it. I also added a small configuration section, so the citation key format and maximum length can be customized if needed. The default behavior is still the same as before: author_year_full_title with a 40-character limit.
Thanks again for pointing this out!
Good luck with your research!(ノ*・▽・)ノ~
P.S. I later came across this discussion on the Zotero Forums and realized that it would also be useful to reuse the citation key stored in Zotero instead of always generating a new one. I've now added an option for that as well.Set useStoredCitationKey to true to enable it.
I'm a student and don't use Zotero very heavily myself, so there may be use cases I haven't considered. If you notice anything else that feels inconvenient or have any suggestions for improvement, please feel free to let me know!( `・∀・´)ノ
Zoteroの拡張機能であるBetter BibTeXがHayagriva形式のインポートとエクスポートをサポートしたようです。
現時点では暫定的な実装と位置づけられているようですが、テストの充実度や継続的なアップデートの受けやすさを考えると今後はこのTranslatorよりもBetter BibTeXを利用することを推奨します。実装者による補足はこちらのコメントを参照してください。
Windows: C:\Users\{Name}\Zotero\translators
macOS: /Users/{Name}/Zotero/translators