Skip to content

Instantly share code, notes, and snippets.

@FeralFlora
Last active June 10, 2024 11:09
Show Gist options
  • Save FeralFlora/73814b775286476969976af17f080091 to your computer and use it in GitHub Desktop.
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.
/*
* 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!');
}
});
}
/*
* 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);
/*
* 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