Last active
August 21, 2024 06:52
-
-
Save FeralFlora/73814b775286476969976af17f080091 to your computer and use it in GitHub Desktop.
A Kanban generating script that uses tags to merge Zotero items into lists of a Kanban board in Obsidian, and updates the reading statuses of items in the process π Setup and usage guide: https://share.note.sx/e612da498b038c3e5e367043782e2b59 Need support? Head to https://discord.com/channels/686053708261228577/1176414557900648541 or comment below.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Zotero Kanban Reading List by FeralFlora // https://github.com/FeralFlora/ | |
* Version 1.6.1 | |
* Setup and usage guide at: https://share.note.sx/e612da498b038c3e5e367043782e2b59 | |
* Support at: https://discord.com/channels/686053708261228577/1176414557900648541 | |
*/ | |
const fs = require('fs'); | |
const KANBAN_PATH = "Kanban board file path"; | |
const BIBLIOGRAPHY_PATH = "Bibliography file path"; | |
const IMPORT_FOLDER = "Zotero Integration import folder"; | |
const TAGS = "Zotero tag hierarchy"; | |
const HEADINGS = "Kanban list hierarchy"; | |
const EMOJI = "Prepend lists with emojis?"; | |
const IMPORT_COLLECTIONS = "Import collections?"; | |
const COLLECTION_FORMAT = "Collection format"; | |
const FAVORITE_TAG = "'Favorite' tag"; | |
const LINK_TEXT = "Link text for Zotero links"; | |
const TAG_ARRAY = "Tags to import"; | |
const CITEKEY_PREFIX = "Citekey @ prefix"; | |
const FILTER_TAG = "Filter the bibliography by tag"; | |
module.exports = { | |
entry: start, | |
settings: { | |
author: "FeralFlora", | |
name: "Zotero Kanban Reading List", | |
options: { | |
[KANBAN_PATH]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "C:/Path/to/Kanban.md", | |
description: "Absolute path to the Kanban board.", | |
}, | |
[BIBLIOGRAPHY_PATH]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "C:/Path/to/Bibliography.json", | |
description: "Absolute path to the Bibliography JSON file (Better JSON).", | |
}, | |
[IMPORT_FOLDER]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "03 - Source notes/Zotero/", | |
description: "This is the folder where Zotero Integration imports your literature notes.", | |
}, | |
[TAGS]: { | |
type: "text", | |
defaultValue: "π, π, π, π, π΅, π", | |
placeholder: "Tag Hierarchy", | |
description: "This is the hierarchy of reading statuses and their associated tags. Tags to the left override tags to the right.", | |
}, | |
[HEADINGS]: { | |
type: "text", | |
defaultValue: "To re-import, Incorporated, Imported, Ready to import, Currently reading, Reading stack", | |
placeholder: "Heading Hierarchy", | |
description: "This is the hierarchy of reading statuses and their associated list headings. The order of the headings must match the order of their associated tags above.", | |
}, | |
[EMOJI]: { | |
type: "checkbox", | |
defaultValue: "false", | |
description: "If your Tag hierarchy tags are emojis, you can set this to true. The emojies are then added in front of each list title.", | |
}, | |
[IMPORT_COLLECTIONS]: { | |
type: "checkbox", | |
defaultValue: "false", | |
description: "If you want to see which collection an item belongs to, set this to true. Choose the format below.", | |
}, | |
[COLLECTION_FORMAT]: { | |
type: "dropdown", | |
defaultValue: "tags", | |
options: [ | |
"tags", | |
"links", | |
], | |
description: "Here, you can choose to format the collections as either tags or links." | |
}, | |
[FAVORITE_TAG]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "favorite", | |
description: "If you have a tag for your favorite items? Specify it here, and it will be placed at the start of each card.", | |
}, | |
[LINK_TEXT]: { | |
type: "text", | |
defaultValue: "π", | |
placeholder: "Open in Zotero", | |
description: "Link text for zotero://select links. Emoji by default to save space.", | |
}, | |
[TAG_ARRAY]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "list, of, tags", | |
description: "List of tags to import into the Kanban cards (minus the #).", | |
}, | |
[CITEKEY_PREFIX]: { | |
type: "checkbox", | |
defaultValue: "true", | |
description: "Toggle off if you don't want to have an @ in front of the citekey in the links to your literature notes.", | |
}, | |
[FILTER_TAG]: { | |
type: "text", | |
defaultValue: "", | |
placeholder: "e.g. chapter1, primary", | |
description: "If you want to filter the bibliography by tags, specify the tags here, separated by a comma. Only items with these tags will be imported.", | |
} | |
}, | |
}, | |
}; | |
let QuickAdd; | |
let Settings; | |
// This citekey:key dictionary is for the Zotero web API (in the future), the alias maker and the star tag. | |
let keyDictionary = {}; | |
// This is the main function that runs everything else | |
async function start(params, settings) { | |
QuickAdd = params; | |
Settings = settings; | |
const markdownFile = Settings[KANBAN_PATH]; | |
const jsonFile = Settings[BIBLIOGRAPHY_PATH]; | |
const tags = Settings[TAGS]; | |
const tagArray = tags.split(",").map(tag => tag.trim()); | |
console.log("Tags:", tagArray); | |
const headings = Settings[HEADINGS]; | |
const headingArray = headings.split(",").map((heading) => heading.trim()); | |
console.log("Headings:", headingArray); | |
let tagHierarchy = {}; | |
tagHierarchy = tagArray.map((tag, index) => { | |
return { | |
tag: tag, | |
heading: headingArray[index], | |
}; | |
}); | |
console.log("Tag hierarchy:", tagHierarchy); | |
let markdownData = await fs.promises.readFile(markdownFile, 'utf-8'); | |
const kanbanPropertyRegex = /^---\n([\s\S]*?)\n---/; | |
const kanbanSettingsRegex = /^%%\skanban:settings\n`{3}\n(\{.*?\})\n`{3}\n%{2}/m; | |
const kanbanPropertyMatch = markdownData.match(kanbanPropertyRegex); | |
const kanbanSettingsMatch = markdownData.match(kanbanSettingsRegex); | |
const fallbackProperties = "kanban-plugin: basic"; | |
const fallbackSettings = `{"kanban-plugin":"basic"}`; | |
const kanbanProperties = kanbanPropertyMatch ? kanbanPropertyMatch[1] : fallbackProperties; | |
const kanbanSettings = kanbanSettingsMatch ? kanbanSettingsMatch[1] : fallbackSettings; | |
let jsonData = await fs.promises.readFile(jsonFile, 'utf-8'); | |
let jsonParsed = JSON.parse(jsonData); | |
let jsonMap = jsonProcessor(jsonParsed, keyDictionary, tagHierarchy, Settings); | |
console.log("Key dictionary", keyDictionary); | |
console.log("JSON Map:", jsonMap); | |
let markdownMap = createMarkdownMap(markdownData, Settings); | |
console.log("Markdown Map:", markdownMap); | |
let mergedMap = mergeMaps(jsonMap, markdownMap, tagHierarchy); | |
console.log("Merged Map:", mergedMap); | |
kanbanBuilder(mergedMap, kanbanProperties, kanbanSettings, keyDictionary, Settings, markdownFile, tagHierarchy); | |
} | |
// Function to process the json | |
function jsonProcessor(data, keyDictionary, tagHierarchy, Settings) { | |
// Inclusion filter by tag | |
const filterTagString = Settings[FILTER_TAG]; | |
let filterTags = filterTagString ? filterTagString.split(",").map(tag => tag.trim()) : null; | |
console.log("Filter tag:", filterTags); | |
let jsonGroups = new Map(); | |
processItems: for (const item in data.items) { | |
let currentGroup = ''; | |
let tags = data.items[item].tags.map(tagObject => tagObject.tag); | |
//console.log(tags); | |
// Filter json bibliography to only include items with the specified filterTag | |
if (filterTags && !filterTags.every(filterTag => tags.includes(filterTag))) { | |
continue processItems; | |
} | |
let citekey = data.items[item].citationKey; | |
// console.log(citekey); | |
let itemID = data.items[item].itemID; | |
let collections = []; | |
let key = data.items[item].key; | |
if (citekey && tags.includes(Settings[FAVORITE_TAG])) { | |
keyDictionary[citekey] = {"key": key, "star": true}; | |
} else { | |
keyDictionary[citekey] = {"key": key, "star": false}; | |
} | |
keyDictionary[citekey]["date"] = data.items[item].date; | |
keyDictionary[citekey]["title"] = data.items[item].shortTitle ? data.items[item].shortTitle : data.items[item].title; | |
if (Array.isArray(data.items[item].creators)) { | |
var creatorsArray = Array.from(data.items[item].creators); | |
} | |
keyDictionary[citekey].creators = creatorsArray; | |
keyDictionary[citekey].tags = []; | |
// Add the specified tags to include in the Kanban to the keyDictionary | |
for (let i = 0; i < tags.length; i++) { | |
let tag = tags[i]; | |
if (Settings[TAG_ARRAY].includes(tag)) { | |
keyDictionary[citekey].tags.push(tag); | |
} | |
} | |
for (let i = 0; i < tagHierarchy.length; i++) { | |
let tagToCheck = tagHierarchy[i].tag; | |
if (tags.includes(tagToCheck)) { | |
currentGroup = tagHierarchy[i].heading; | |
if (!jsonGroups.has(currentGroup)) { | |
jsonGroups.set(currentGroup, new Set()); | |
} | |
jsonGroups.get(currentGroup).add(citekey.trim()); | |
break; | |
} | |
} | |
for (const collection in data.collections) { | |
// Check if the item is in the collection | |
if (data.collections[collection].items.includes(itemID)) { | |
const collectionName = data.collections[collection].name; | |
const dashCollection = collectionName.replace(/ /g, "-"); | |
let formattedParent; | |
let formattedCollection; | |
/** | |
* Formats the collection name and pushes it to the collections array, starting with the bottom level collections. | |
* If the collection has a parent, formats the parent name and pushes it as well. | |
*/ | |
if (!isParent(collection)) { // Start with bottom level collections | |
if (hasParent(collection)) { // Check if the collection has a parent | |
const parentID = data.collections[collection].parent; | |
const parentName = data.collections[parentID].name; // Get the name of the parent collection from the ID | |
if (Settings[COLLECTION_FORMAT] === "tags") { // Check the collection format | |
// Format the parent name and the collection name | |
formattedParent = parentName.replace(/ /g, "-"); // Replace spaces with hyphens | |
formattedCollection = dashCollection; | |
} else if (Settings[COLLECTION_FORMAT] === "links") { | |
// No change needed for the collection name | |
formattedParent = parentName; | |
formattedCollection = collectionName; | |
} | |
if (!collections.includes(formattedParent)) { // Check if the parent is already in the collections array | |
collections.push(formattedParent); // Push the formatted parent name | |
collections.push(formattedCollection); // Push the formatted collection name | |
} else { | |
collections.push(formattedCollection); // Push the formatted collection name | |
} | |
} else { // If the collection doesn't have a parent, just add the collection name | |
collections.push(formattedCollection); // Push the formatted collection name | |
} | |
} | |
} | |
} | |
keyDictionary[citekey].collections = collections; | |
} | |
return jsonGroups; | |
function isParent(collection) { | |
const children = data.collections[collection].collections; | |
//console.log("Children:", children) | |
if (children.length === 0) { | |
return false; | |
} else { | |
return true; | |
} | |
} | |
function hasParent(collection) { | |
const parent = data.collections[collection].parent; | |
//console.log("parent:", parent) | |
if (parent.length === 0) { | |
return false; | |
} else { | |
return true; | |
} | |
} | |
} | |
// Functional snippet to save citekeys to different sets based on the preceding heading | |
function createMarkdownMap(data, settings) { | |
let Settings = settings; | |
let headingCitekeys = new Map(); // Create a map to store the citekeys for each heading | |
let currentHeading = ''; | |
const headingRegex = new RegExp(/^#+\s(.*)$/); | |
// We make the @ optional, so that the regex works even when refreshing after switching the @ prefix setting | |
const citeKeyRegex = new RegExp(`${Settings[IMPORT_FOLDER]}/@?([a-zA-Z0-9_:.#$%&+?<>~\/-]{1,})\|`); | |
// It's important that the dash is at the end, otherwise it will split citekeys with dashes and only capture the beginning | |
const combinedRegex = new RegExp(`${headingRegex.source}|${citeKeyRegex.source}`, "gm"); | |
data.replace(combinedRegex, (_match, heading, citeKey) => { | |
if (heading) { | |
if (Settings[EMOJI] == true) { | |
// Update the current heading (remove the emoji and the space) | |
currentHeading = heading.slice(2).trim(); | |
} else { | |
// Update the current heading | |
currentHeading = heading.trim() | |
} | |
headingCitekeys.set(currentHeading, new Set()); // Create a new set for the heading if it doesn't exist | |
} else if (currentHeading && citeKey) { | |
headingCitekeys.get(currentHeading).add(citeKey); // Add the citekey to the set of the current heading | |
} | |
}); | |
return headingCitekeys; | |
} | |
// This function merges two maps without creating duplicates, following the tagHiearchy to solve conflicts in reading status | |
function mergeMaps(map1, map2, hierarchy) { | |
let mergedMap = new Map(); | |
let workflowOrder = hierarchy.reverse(); | |
// Function to order the map according to the tagHierarchy | |
//let map1Ordered = orderFix(map1, reversedOrder); | |
//let map2Ordered = orderFix(map2, reversedOrder); | |
//console.log("JSON Ordered:", map1Ordered); | |
//console.log("Markdown Ordered:", map2Ordered); | |
// Converts the map keys to their tagHiearchy index. This enables the conflict resolution logic. | |
function indexConvert(map) { | |
let mapIndex = new Map(); | |
for (const [group, elements] of map) { | |
let index = hierarchy.indexOf(hierarchy.find(item => item.heading === group)); | |
mapIndex.set(index, new Set(elements)); | |
} | |
return mapIndex; | |
} | |
// JSON is map1, Markdown is map2 | |
const map1Index = indexConvert(map1); | |
const map2Index = indexConvert(map2); | |
//console.log("JSON index:", map1Index); | |
//console.log("Markdown index:", map2Index); | |
//This is where the conflict resolution / magic happens | |
function conflictSolver(mapp1, mapp2) { | |
for (const [group, elements] of mapp1) { | |
if (elements.size === 0) { | |
new Notice(`${hierarchy[group].heading} is empty`); | |
addToMergedMap(mergedMap, group, null); | |
} else { | |
for (const element of elements) { | |
const group2 = findGroupInMap(mapp2, element); | |
if (group2 !== null && (group === group2)) { | |
//console.log(`${element} stayed in ${hierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} else if (group2 !== null && (group !== group2)) { | |
let decider = parseInt(group) - parseInt(group2); | |
//console.log(decider); | |
// The decider logic is reversed because the order is reversed | |
if (decider > 0) { | |
//new Notice(`Conflict: ${element} is present in ${hierarchy[group].heading} and ${hierarchy[group2].heading}`); | |
new Notice(`Kanban updated by Zotero: ${element} is moved from ${hierarchy[group2].heading} to ${hierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} else if (decider < 0) { | |
//console.log(`Kanban overrules Zotero: ${element} stays in ${hierarchy[group2].heading}`); | |
addToMergedMap(mergedMap, group2, element); | |
} | |
} else if (group2 === null) { | |
new Notice(`New item: ${element} was added to ${hierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} | |
} | |
} | |
} | |
for (const [group, elements] of mapp2) { | |
for (const element of elements) { | |
if (!findGroupInMap(mergedMap, element)) { | |
new Notice(`${element} is only present in the Kanban. Keeping it in ${hierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} | |
} | |
} | |
// Fix the order of the lists | |
mergedMap = orderFix(mergedMap, workflowOrder); | |
return mergedMap; | |
} | |
function findGroupInMap(map, element) { | |
for (const [group, elements] of map) { | |
if (elements.has(element)) { | |
return group; | |
} | |
} | |
return null; | |
} | |
function addToMergedMap(map, group, element) { | |
const heading = hierarchy[group]?.heading; | |
const set = map.get(heading) ?? new Set(); | |
if (element != null) { | |
set.add(element); | |
} | |
map.set(heading, set); | |
return map; | |
} | |
function orderFix(map, order) { | |
let orderedMap = new Map(); | |
for (let i = 0; i < order.length; i++) { | |
let heading = order[i].heading; | |
if (map.has(heading)) { | |
let elements = map.get(heading); | |
orderedMap.set(heading, new Set(elements)); | |
} else { | |
orderedMap.set(heading, new Set()); | |
} | |
} | |
//console.log("Ordered Map:", orderedMap); | |
return orderedMap; | |
} | |
return conflictSolver(map1Index, map2Index) | |
} | |
// This makes an APA alias for each item | |
function aliasMaker(keyDictionary, element) { | |
let alias = ''; | |
if (keyDictionary[element] !== undefined) { | |
let creators = keyDictionary[element].creators; | |
const date = new Date(keyDictionary[element].date); | |
const fullDate = String(keyDictionary[element].date); | |
const yearMatch = fullDate.match(/.*(\d{4})/); | |
const year = !isNaN(date) ? date.getFullYear() : yearMatch ? yearMatch[1] : null; | |
const title = keyDictionary[element].title; | |
//console.log("Creators:", creators); | |
if (creators.length !== 0) { | |
const firstAuthor = creators[0]['name'] ? creators[0]['name'] : creators[0]['lastName'] ? creators[0]['lastName'] : null; | |
alias += `${firstAuthor}`; | |
} | |
if (creators.length > 1) { | |
const secondAuthor = creators[1]['name'] ? creators[1]['name'] : creators[1]['lastName'] ? creators[1]['lastName'] : null; | |
if (creators.length === 2) { | |
alias += ` & ${secondAuthor}`; | |
} else if (creators.length > 2) { | |
alias += ` et al.`; | |
} | |
} | |
if (year !== null && year !== undefined) { | |
alias += ` (${year}) - ${title}`; | |
} else { | |
alias += `${title}`; | |
} | |
} else { | |
console.log(`No Zotero data for ${element}`); | |
} | |
return alias | |
} | |
// This builds the document | |
function kanbanBuilder(mergedData, kanbanProperties, kanbanSettings, dict, settings, file, hierarchy) { | |
let Settings = settings; | |
let documentContent = `---\n` + kanbanProperties + `\n---\n`; | |
const importFolder = Settings[IMPORT_FOLDER]; | |
for (const [group, elements] of mergedData) { | |
if (Settings[EMOJI] == true) { | |
let emoji = hierarchy.find(item => item.heading === group)?.tag || ''; | |
documentContent += `\n# ${emoji} ${group}\n\n`; | |
} else { | |
documentContent += `\n# ${group}\n\n`; | |
} | |
for (const element of elements) { | |
const formattedCitekey = Settings[CITEKEY_PREFIX] ? '@' + element : element; | |
if (keyDictionary[element] !== undefined) { | |
const alias = aliasMaker(dict, element); | |
const favorite = keyDictionary[element].star ? `#${Settings[FAVORITE_TAG]} ` : ''; | |
const elementKey = keyDictionary[element].key; | |
//const title = keyDictionary[element].title; | |
const collections = String(keyDictionary[element].collections); | |
let tagCollections = ''; | |
if (Settings[COLLECTION_FORMAT] === "tags" ) { | |
tagCollections = collections.length !== 0 && settings[IMPORT_COLLECTIONS] == true ? collections.split(',').map(collection => `#${collection.trim()}`).join(' ') : ''; | |
} else if (Settings[COLLECTION_FORMAT] === "links") { | |
tagCollections = collections.length !== 0 && settings[IMPORT_COLLECTIONS] == true ? collections.split(',').map(collection => `[[${collection.trim()}]]`).join(' ') : ''; | |
} | |
const tagsRaw = String(keyDictionary[element].tags); | |
const splitTags = tagsRaw.length > 0? tagsRaw.split(',').map(tag => `#${tag.trim()}`).join(' ') : ''; | |
//console.log("Tags:", splitTags); | |
documentContent += `- [ ] ${favorite}[[${importFolder.endsWith('/') ? importFolder : importFolder + '/'}${formattedCitekey} | ${alias}]] [${Settings[LINK_TEXT]}](zotero://select/library/items/${elementKey}) ${tagCollections} ${splitTags}\n`; | |
} else { | |
new Notice(`Can't build alias for ${element} without Zotero data, keeping format as citekey.\n Watch out for potential duplication!`); | |
documentContent += `- [ ] [[${importFolder.endsWith('/') ? importFolder : importFolder + '/'}${formattedCitekey}${formattedCitekey} | ${formattedCitekey}]]\n`; | |
} | |
} | |
} | |
documentContent += `\n%% kanban:settings\n` + "```\n" + kanbanSettings + "\n```" + `\n%%`; | |
fs.writeFile(file, documentContent, (err) => { | |
if (err) { | |
new Notice('Error writing file:', err); | |
} else { | |
new Notice('Kanban updated successfully!'); | |
} | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Zotero Kanban Reading List by FeralFlora // https://github.com/FeralFlora/ | |
* Setup and usage guide at: https://file.obsidianshare.com/70/e612da498b038c3e5e367043782e2b59.html | |
* Support at: https://discord.com/channels/686053708261228577/1151531990106001408 | |
*/ | |
const fs = require('fs'); | |
/*------------------------------------------------------------------------------------------------------*/ | |
// THE FOLLOWING SETTINGS ARE REQUIRED. The script will not work if you do not specify them. | |
// Path to existing kanban file. Create it before running the script! | |
// TODO Add handling for non existing files | |
const markdownFile = "C:/path/to/kanban.md"; | |
// Path to the json bibliography from Zotero. I've been testing with BetterBibtex JSON. | |
const jsonFile = "C:/path/to/bibliography.json"; | |
// Specify your Zotero Import folder here. The example below is for reference on the expected format. | |
const importFolder = "03 - Source notes/Zotero/"; | |
// Specify the tags you use in Zotero in a linear hierarchy that represents your reading workflow. Tags at the top take precedence over tags further down. | |
// In this example, the tags at the bottom are the first stage of the reading workflow. They are lowest in the hierarchy, and tags above will supersede them. | |
// You can add and remove tag, heading pairs as you see fit, to fit your workflow. | |
const tagHierarchy = [ | |
{ tag: 'π', heading: 'To re-import' }, | |
{ tag: 'ποΈ', heading: 'Incorporated' }, | |
{ tag: 'π', heading: 'Imported' }, | |
{ tag: 'π', heading: 'Ready to import' }, | |
{ tag: 'π΅', heading: 'Currently reading' }, | |
{ tag: 'π', heading: 'Reading stack' } | |
]; | |
/*------------------------------------------------------------------------------------------------------*/ | |
// THE FOLLOWING SETTINGS ARE OPTIONAL. You can change them to customize your Kanban. | |
// This is a setting on whether your tags are emojis. If they are, they will be placed at the start of the list heading in the Kanban. | |
// Set it to false if this is not what you want. | |
const emoji = true; | |
// This is your "favorite" tag. I use β, but you might use #favorite or something else. | |
// Don't put a hashtag in the starTag variable. | |
const starTag = "β"; | |
// Here, you can customize the link text in the links back to Zotero | |
const linkText = "π"; | |
// This setting controls whether you want to show collections as tags in the Kanban | |
const importCollections = true; | |
// Here, you can specify an array of Zotero tags that you want to import into the Kanban. | |
// The tags below are just for reference on the format. | |
const tagsArray = ["remember", "inspiration", "review"]; | |
// END OF SETTINGS. DON'T CHANGE ANYTHING BELOW HERE UNLESS YOU KNOW WHAT YOU ARE DOING! | |
/*------------------------------------------------------------------------------------------------------*/ | |
// This citekey:key dictionary is for the Zotero web API (in the future), the alias maker and the star tag. | |
let keyDictionary = {}; | |
// This is the main function that runs everything else | |
async function readData(json, markdown) { | |
let markdownData = await fs.promises.readFile(markdown, 'utf-8'); | |
const kanbanHeadingRegex = /^---\n([\s\S]*?)\n---/; | |
const kanbanSettingsRegex = /^%%\skanban:settings\n`{3}\n(\{.*?\n)`{3}\n%{2}/m; | |
const kanbanHeadingMatch = await markdownData.match(kanbanHeadingRegex); | |
const kanbanSettingsMatch = await markdownData.match(kanbanSettingsRegex); | |
const kanbanHeading = kanbanHeadingMatch ? kanbanHeadingMatch[1] : `---\n`+`kanban-plugin: basic`+`\n---`; | |
const kanbanSettings = kanbanSettingsMatch ? kanbanSettingsMatch[1] : "No match"; | |
let jsonData = await fs.promises.readFile(json, 'utf-8'); | |
let jsonParsed = JSON.parse(jsonData); | |
let jsonMap = jsonProcessor(jsonParsed, keyDictionary); | |
//console.log("Key dictionary", keyDictionary); | |
//console.log("JSON Map:", jsonMap); | |
let markdownReadingList = createMarkdownMap(markdownData); | |
//console.log("Markdown Map:", markdownReadingList); | |
let mergedMap = mergeMaps(jsonMap, markdownReadingList); | |
//console.log("Merged Map:", mergedMap); | |
kanbanBuilder(mergedMap, kanbanHeading, kanbanSettings, keyDictionary); | |
} | |
// Function to process the json | |
function jsonProcessor(data, keyDictionary) { | |
let jsonGroups = new Map(); | |
for (const item in data.items) { | |
let currentGroup = ''; | |
let citekey = data.items[item].citationKey; | |
// console.log(citekey); | |
let tags = data.items[item].tags.map(tagObject => tagObject.tag); | |
//console.log(tags); | |
let itemID = data.items[item].itemID; | |
let collections = []; | |
let key = data.items[item].key; | |
if (citekey && tags.includes(starTag)) { | |
keyDictionary[citekey] = {"key": key, "star": true}; | |
} else { | |
keyDictionary[citekey] = {"key": key, "star": false}; | |
} | |
keyDictionary[citekey]["date"] = data.items[item].date; | |
keyDictionary[citekey]["title"] = data.items[item].shortTitle? data.items[item].shortTitle : data.items[item].title; | |
if (Array.isArray(data.items[item].creators)) { | |
var creatorsArray = Array.from(data.items[item].creators); | |
} | |
keyDictionary[citekey].creators = creatorsArray; | |
keyDictionary[citekey].tags = []; | |
for (let i = 0; i < tags.length; i++) { | |
let tag = tags[i]; | |
if (tagsArray.includes(tag)) { | |
keyDictionary[citekey].tags.push(tag); | |
} | |
} | |
for (let i = 0; i < tagHierarchy.length; i++) { | |
let tagToCheck = tagHierarchy[i].tag; | |
if (tags.includes(tagToCheck)) { | |
currentGroup = tagHierarchy[i].heading; | |
if (!jsonGroups.has(currentGroup)) { | |
jsonGroups.set(currentGroup, new Set()); | |
} | |
jsonGroups.get(currentGroup).add(citekey.trim()); | |
break; | |
} | |
} | |
for (const collection in data.collections) { | |
if (data.collections[collection].items.includes(itemID)) { | |
const collectionName = data.collections[collection].name; | |
const dashCollection = collectionName.replace(/ /g, "-"); | |
if (!isParent(collection)) { | |
if (hasParent(collection)) { | |
const parentID = data.collections[collection].parent; | |
const parentName = data.collections[parentID].name; | |
const dashParent = parentName.replace(/ /g, "-"); | |
if (!collections.includes(dashParent)) { | |
collections.push(dashParent); | |
collections.push(dashCollection); | |
} else { | |
collections.push(dashCollection); | |
} | |
} else { | |
collections.push(dashCollection); | |
} | |
} | |
} | |
} | |
keyDictionary[citekey].collections = collections; | |
} | |
return jsonGroups; | |
function isParent(collection) { | |
const children = data.collections[collection].collections; | |
//console.log("Children:", children) | |
if (children.length === 0) { | |
return false; | |
} else { | |
return true; | |
} | |
} | |
function hasParent(collection) { | |
const parent = data.collections[collection].parent; | |
//console.log("parent:", parent) | |
if (parent.length === 0) { | |
return false; | |
} else { | |
return true; | |
} | |
} | |
} | |
// Functional snippet to save citekeys to different sets based on the preceding heading | |
function createMarkdownMap(data) { | |
let headingCitekeys = new Map(); // Create a map to store the citekeys for each heading | |
let currentHeading = ''; | |
// It's important that the dash is at the end, otherwise it will split citekeys with dashes and only capture the beginning | |
data.replace(/^#+\s(.*)$|@([a-zA-Z0-9_:.#$%&+?<>~/-]*)/gm, (_match, heading, citeKey) => { | |
if (heading) { | |
currentHeading = heading.slice(2).trim(); // Update the current heading (remove the emoji and the space) | |
headingCitekeys.set(currentHeading, new Set()); // Create a new set for the heading if it doesn't exist | |
} else if (currentHeading && citeKey) { | |
headingCitekeys.get(currentHeading).add(citeKey); // Add the citekey to the set of the current heading | |
} | |
}); | |
return headingCitekeys; | |
} | |
// This function merges two maps without creating duplicates, following the tagHiearchy to solve conflicts in reading status | |
function mergeMaps(map1, map2) { | |
let mergedMap = new Map(); | |
let reversedOrder = tagHierarchy.reverse(); | |
// Function to order the map according to the tagHierarchy | |
function orderFix(map, order) { | |
let orderedMap = new Map(); | |
for (let i = 0; i < order.length; i++) { | |
let heading = order[i].heading; | |
if (map.has(heading)) { | |
let elements = map.get(heading); | |
orderedMap.set(heading, new Set(elements)); | |
} else { | |
orderedMap.set(heading, new Set()); | |
} | |
} | |
//console.log("Ordered Map:", orderedMap); | |
return orderedMap; | |
} | |
let map1Ordered = orderFix(map1, reversedOrder); | |
let map2Ordered = orderFix(map2, reversedOrder); | |
//console.log("Map 1 Ordered:", map1Ordered); | |
//console.log("Map 2 Ordered:", map2Ordered); | |
// Converts the map keys to their tagHiearchy index. This enables the conflict resolution logic. | |
function indexConvert(map) { | |
let mapIndex = new Map(); | |
for (const [group, elements] of map) { | |
let index = tagHierarchy.indexOf(tagHierarchy.find(item => item.heading === group)); | |
mapIndex.set(index, new Set(elements)); | |
} | |
return mapIndex; | |
} | |
let map1Index = indexConvert(map1Ordered); | |
const map2Index = indexConvert(map2Ordered); | |
//console.log("Map1 index:", map1Index); | |
//console.log("Map2 index:", map2Index); | |
function conflictSolver(mapp1, mapp2) { | |
for (const [group, elements] of mapp1) { | |
if (elements.size === 0) { | |
console.log(`${tagHierarchy[group].heading} is empty`); | |
addToMergedMap(mergedMap, group, null); | |
} else { | |
for (const element of elements) { | |
const group2 = findGroupInMap(mapp2, element); | |
if (group2 !== null && (group === group2)) { | |
//console.log(`${element} stayed in ${tagHierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} else if (group2 !== null && (group !== group2)) { | |
console.log(`Conflict: ${element} is present in ${tagHierarchy[group].heading} and ${tagHierarchy[group2].heading}`); | |
let decider = parseInt(group) - parseInt(group2); | |
//console.log(decider); | |
// The decider logic is reversed because the order is reversed | |
if (decider > 0) { | |
console.log(`Zotero overrules Kanban: ${element} is moved from ${tagHierarchy[group2].heading} to ${tagHierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} else if (decider < 0) { | |
//console.log(`Kanban overrules Zotero: ${element} stays in ${tagHierarchy[group2].heading}`); | |
addToMergedMap(mergedMap, group2, element); | |
} | |
} else if (group2 === null) { | |
console.log(`${element} is only present in Zotero. Adding it to ${tagHierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} | |
} | |
} | |
} | |
for (const [group, elements] of mapp2) { | |
for (const element of elements) { | |
if (!findGroupInMap(mergedMap, element)) { | |
console.log(`${element} is only present in the Kanban. Keeping it in ${tagHierarchy[group].heading}`); | |
addToMergedMap(mergedMap, group, element); | |
} | |
} | |
} | |
return mergedMap; | |
} | |
return conflictSolver(map1Index, map2Index); | |
function findGroupInMap(map, element) { | |
for (const [group, elements] of map) { | |
if (elements.has(element)) { | |
return group; | |
} | |
} | |
return null; | |
} | |
// Helper function so I don't have to retype the same code | |
function addToMergedMap(map, group, element) { | |
let heading = tagHierarchy[group].heading; | |
if (map.has(heading)) { | |
map.get(heading).add(element); | |
} else if (element == null && !map.has(heading)) { | |
map.set(heading, new Set()); | |
} else { | |
map.set(heading, new Set([element])); | |
} | |
return map; | |
} | |
} | |
// This makes an APA alias for each item | |
function aliasMaker(keyDictionary, element) { | |
let alias = ''; | |
if (keyDictionary[element] !== undefined) { | |
let creators = keyDictionary[element].creators; | |
const date = new Date(keyDictionary[element].date); | |
const fullDate = String(keyDictionary[element].date); | |
const yearMatch = fullDate.match(/.*(\d{4})/); | |
const year = !isNaN(date) ? date.getFullYear() : yearMatch ? yearMatch[1] : null; | |
const title = keyDictionary[element].title; | |
//console.log("Creators:", creators); | |
if (creators.length !== 0) { | |
const firstAuthor = creators[0]['name'] ? creators[0]['name'] : creators[0]['lastName'] ? creators[0]['lastName'] : null; | |
alias += `${firstAuthor}`; | |
} | |
if (creators.length > 1) { | |
const secondAuthor = creators[1]['name'] ? creators[1]['name'] : creators[1]['lastName'] ? creators[1]['lastName'] : null; | |
if (creators.length === 2) { | |
alias += ` & ${secondAuthor}`; | |
} else if (creators.length > 2) { | |
alias += ` et al.`; | |
} | |
} | |
if (year !== undefined) { | |
alias += ` (${year}) | ${title}`; | |
} else { | |
alias += `${title}`; | |
} | |
} else { | |
console.log(`No Zotero data for ${element}`); | |
} | |
return alias | |
} | |
// This builds the document | |
function kanbanBuilder(mergedData, kanbanHeading, kanbanSettings, dict) { | |
let documentContent = `---\n` + kanbanHeading + `\n---\n`; | |
for (const [group, elements] of mergedData) { | |
if (emoji == true) { | |
let emoji = tagHierarchy.find(item => item.heading === group)?.tag || ''; | |
documentContent += `\n# ${emoji} ${group}\n\n`; | |
} else { | |
documentContent += `\n# ${group}\n\n`; | |
} | |
for (const element of elements) { | |
if (keyDictionary[element] !== undefined) { | |
const alias = aliasMaker(dict, element); | |
const star = keyDictionary[element].star ? `#${starTag} ` : ''; | |
const elementKey = keyDictionary[element].key; | |
//const title = keyDictionary[element].title; | |
const collections = String(keyDictionary[element].collections); | |
const tagCollections = collections.length !== 0 && importCollections == true ? collections.split(',').map(collection => `#${collection.trim()}`).join(' ') : ''; | |
const tagsString = String(keyDictionary[element].tags); | |
const splitTags = tagsString.length > 0? tagsString.split(',').map(tag => `#${tag.trim()}`).join(' ') : ''; | |
documentContent += `- [ ] ${star}[[${importFolder}@${element}|${alias}]] [${linkText}](zotero://select/library/items/${elementKey}) ${tagCollections} ${splitTags}\n`; | |
} else { | |
console.log(`Can't build alias for ${element} without Zotero data, keeping format as citekey.`); | |
documentContent += `- [ ] [[${importFolder}@${element}|@${element}]]\n`; | |
} | |
} | |
} | |
documentContent += `\n%% kanban:settings\n` + "```\n" + kanbanSettings + "```" + `\n%%`; | |
fs.writeFile(markdownFile, documentContent, (err) => { | |
if (err) { | |
console.error('Error writing file:', err); | |
} else { | |
console.log('File written successfully'); | |
} | |
}); | |
} | |
// Function export for Templater | |
module.exports = readData; | |
// Usage | |
readData(jsonFile, markdownFile); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Kanban-plugin column colors | |
* Adapted from snippet by @imstevenxyz here: https://github.com/mgmeyers/obsidian-kanban/issues/755#issuecomment-1634839726 | |
*/ | |
:root{ | |
--kanban-ccolor-column-1: rgb(128, 131, 153); | |
--kanban-ccolor-column-2: rgb(30, 102, 245); | |
--kanban-ccolor-column-3: rgb(64, 160, 43); | |
--kanban-ccolor-column-4: rgb(223, 142, 29); | |
--kanban-ccolor-column-5: rgb(124, 78, 41); | |
--kanban-ccolor-column-6: rgb(189, 104, 246); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(1) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-1); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(1) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-1); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(2) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-2); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(2) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-2); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(3) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-3); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(3) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-3); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(4) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-4); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(4) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-4); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(5) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-5); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(5) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-5); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(6) | |
.kanban-plugin__lane { | |
border-color: var(--kanban-ccolor-column-6); | |
} | |
.kanban-plugin__lane-wrapper:nth-of-type(6) | |
.kanban-plugin__lane-header-wrapper { | |
background-color: var(--kanban-ccolor-column-6); | |
} | |
.kanban-plugin__lane { | |
border: 4px solid; | |
} | |
.kanban-plugin__lane-grip, | |
.kanban-plugin__lane-header-wrapper, | |
.kanban-plugin__lane-title-count, | |
.kanban-plugin__lane-settings-button { | |
color: white !important; | |
} | |
/* | |
Kanban-plugin UI improvements for new column colors | |
*/ | |
.kanban-plugin__lane-header-wrapper, | |
.kanban-plugin__item-button-wrapper, | |
.kanban-plugin__item-form { | |
border: none; | |
} | |
.kanban-plugin__item { | |
border: 1px dashed; | |
border-color: gray; | |
} | |
.kanban-plugin__new-item-button { | |
box-shadow: none !important; | |
} | |
.kanban-plugin__new-item-button:hover { | |
background-color: rgba(255,255,255,0.055); | |
} | |
.kanban-plugin__item-form | |
.kanban-plugin__item-input { | |
background-color: transparent; | |
} | |
/* a.tag.kanban-plugin__item-tag { | |
color: darkgray; | |
} */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment