Skip to content

Instantly share code, notes, and snippets.

@zsviczian
Created July 16, 2025 19:00
Show Gist options
  • Save zsviczian/2fd27e4431852d1ee8eabf08d6cdcd17 to your computer and use it in GitHub Desktop.
Save zsviczian/2fd27e4431852d1ee8eabf08d6cdcd17 to your computer and use it in GitHub Desktop.
Build a Searchable Icon Library in Obsidian With Bases

Obsidian Bases Icon Library

WARNING: MAKE SURE YOU DO PROPER TESTING BEFORE UNLEASHING THIS SCRIPT IN YOUR VAULT. In case you need to customize the script to your needs, may use AI such as Sonnet, ChatGPT, Grok, or Gemini to modify the scripts.

This Gist contains the necessary files to create the searchable Icon Library in Obsidian using the Bases feature and the Excalidraw plugin, as demonstrated in my YouTube video.

🎬 Full Setup Guide

For detailed instructions on how to use these files, what they do, and how to configure everything, please watch the full video guide. The video is the primary source of documentation for this workflow.

▶️ Watch the full setup guide on YouTube


✅ Prerequisites

Before you begin, please ensure you have the following installed and enabled:

  • Obsidian v1.9.x or newer.
    • Important: As of the creation of this guide, the Bases feature is only available to Obsidian Catalyst license holders (Insiders). This workflow will not function without it.
  • The Excalidraw plugin for Obsidian v2.13.2 or newer.

📂 What's Included

This Gist contains four key files. Here’s what each one does and where it goes.

1. ExcalidrawStartup.md

  • What it does: Add this to the startup script for the Excalidraw plugin. It adds the custom command to open the library, handles the auto-export logic for your icons, and creates the Assets/nosync/Image Library.base file if it doesn't exist.
  • How to use it: Open ExcalidrawStartup via Excalidraw Plugin Settings under "Excalidraw Automate" and add the contents of this script to the file. If required (e.g. your icons follow a different naming convention) edit the script.

2. WorkspaceMod.css

  • What it does: A simple CSS snippet to improve the appearance of the Bases library cards, especially in dark mode.
  • How to use it: Place this file in your vault's .obsidian/snippets folder and enable it under Settings > Appearance > CSS snippets.

3. BulkExport.js

  • What it does: This is a one-time script you run in the Obsidian Developer Console. It finds all your existing Excalidraw icons (based on my naming convention) and exports them as SVGs so the library can be populated immediately.
  • How to use it: Follow the instructions in the video to run this in the console (Ctrl+Shift+I or Cmd+Option+I).

4. Image Library.base

  • What it does: This is the template for the Bases file itself. The ExcalidrawStartup.md script will automatically create this file for you in the correct location (Assets/No Sync/Image Library.base by default), but it's included here for reference or manual setup.
  • How to use it: You typically don't need to move this file yourself; the startup script handles its creation.

older Solution (Excalidraw Only)

If you don't have access to Obsidian Bases yet, or you prefer the Excalidraw version for some other reason, you can use my previous solution which is based purely on Excalidraw and does not require the Bases feature.

Enjoy building your ultimate icon library

ea = ExcalidrawAutomate;
async function run() {
const files = app.vault.getMarkdownFiles().filter(f=>ea.isExcalidrawFile(f) && f.name.match(/^(?:icon|stickfigure|logo) - /i));
let workingLeaf = app.workspace.getLeaf(true);
//let i = 0; //uncomment this and if statment later for limited debugging.
for (excalidrawFile of files) {
//if(i++>5) continue;
await workingLeaf.openFile(excalidrawFile);
await sleep(200);
while(workingLeaf.view.activeLoader) await sleep(200);
await workingLeaf.view.saveSVG();
}
}
run();

/* #exclude DO NOT ADD DOCUMENT PROPERTIES TO THIS FILE. CONFIGURE PLUGINS THAT AUTOMATICALLY ADD FRONTMATTER TO EXCLUDE THIS FILE. THE FILE WILL BREAK IF IT HAS FRONT MATTER PROPERTIES.

*/
// -----------------------------
// -----------------------------
// Icon Library / Bases Search
// -----------------------------
// -----------------------------
const IMAGE_LIBRARY_FOLDER = ea.obsidian.normalizePath("Assets/nosync");
const IMAGE_LIBRARY_FILENAME = "Image Library.base"
const ICONTYPES = [
  {name: "Icon", pattern: "icon"},
  {name: "Stickfigure", pattern: "stickfigure"},
  {name: "Logo", pattern: "logo"}
];

const IMAGE_LIBRARY_PATH = ea.obsidian.normalizePath(IMAGE_LIBRARY_FOLDER + "/" + IMAGE_LIBRARY_FILENAME);
async function initializeImageLibrary() {
  await ea.checkAndCreateFolder(IMAGE_LIBRARY_FOLDER);
  const syncPlugin = app.internalPlugins.plugins["sync"]?.instance;
  if(syncPlugin && !syncPlugin.ignoreFolders.includes(IMAGE_LIBRARY_FOLDER)) {
    syncPlugin.setIgnoreFolders(syncPlugin.ignoreFolders.concat(IMAGE_LIBRARY_FOLDER));
  }
  const imgLibFile = app.vault.getFileByPath(IMAGE_LIBRARY_PATH);
  if(!imgLibFile) {
    //The bases file is very sensitive to spaces, indents, and formatting
    //take care when modifying this
    const baseTemplate = `formulas:\n` +
      `  Icon: image(file.path)\n` +
      `  keywords: file.name.split(" - ")[1]\n` +
      `  icon-path: link(if(file.ext == "md", "Assets/" + file.name.split(" - ")[0] + "s/" + file.name + ".svg", file.path))\n` +
      `views:\n` +
      `  - type: cards\n` +
      `    name: View\n` +
      `    filters:\n` +
      `      and:\n` +
      `        - /^(icon|stickfigure|logo) \\- /i.matches(file.name.lower())\n` +
      `        - '!file.path.startsWith("Assets/")'\n` +
      `        - /./i.matches(formula.keywords)\n` +
      `    order:\n` +
      `      - formula.keywords\n` +
      `    sort:\n` +
      `      - property: formula.keywords\n` +
      `        direction: ASC\n` +
      `    cardSize: 130\n` +
      `    imageFit: contain\n` +
      `    image: formula.icon-path\n` +
      `    imageAspectRatio: 0.8\n`;
    await app.vault.create(IMAGE_LIBRARY_PATH, baseTemplate);
  }
}

initializeImageLibrary();

async function revealIconLibrary() {
  const file = app.vault.getFileByPath(IMAGE_LIBRARY_PATH);
  if(!file) return;
  let leaf;
  app.workspace.iterateAllLeaves(l=>{
    if(leaf) return;
    if(l.view?.getViewType() === "bases" && l.view.getState().file === file.path) leaf = l;
  });
  if(leaf) {
    app.workspace.revealLeaf(leaf);
    return file;
  }
  
  leaf = app.workspace.getRightLeaf();
  await leaf.openFile(file);
  app.workspace.revealLeaf(leaf);
  return file;
}

if(ea.verifyMinimumPluginVersion("2.13.2")) {
  ea.plugin.addCommand({
    id: "base-filter-keywords",
    name: "Icon Library",
    icon: "images",
    callback: async () => {
      // Check if the active file is a .base file
      const file = await revealIconLibrary();
      if(!file) return false;
  
      let baseContent = await app.vault.read(file);
      // Check if the file has the specific patterns for filtering
      if (!baseContent.includes(".matches(formula.keywords)")) return;
      
      // Create a modal using Obsidian's Modal class
      const Modal = ea.FloatingModal;
      const modal = new Modal(app);
      const { contentEl } = modal;
      
      contentEl.createEl("style", {
        text: `
          input[type="checkbox"]:focus-visible {
            outline: 2px solid ${app.getAccentColor()} !important;
            outline-offset: 2px !important;
          }
        `
      });
      
      // Set title
      contentEl.createEl("h3", {
        text: "Icon Library"
      });
      
      // ---------------------
      // Create keyword filter
      // ---------------------
      const inputContainer = contentEl.createDiv();
      inputContainer.style.margin = "20px 0";
      const input = contentEl.createEl("input", {
        type: "text",
        placeholder: "Enter filter term (leave empty for wildcard, you may use regular expression)",
      });
      input.style.width = "100%";
      input.style.padding = "8px";
      // Extract current keyword filter
      const keywordFilterRegex = /(- +)\/(.*?)\/i?\.matches\(formula\.keywords\)/;
      const keywordMatch = baseContent.match(keywordFilterRegex);
      if (keywordMatch && keywordMatch[2] && keywordMatch[2] !== ".") {
        input.value = keywordMatch[2];
      }
      // Set focus on the input
      setTimeout(() => input.focus(), 50);
      
      // ------------------
      // Create toggle switches for file type filters
      // ------------------
      const toggleContainer = contentEl.createDiv();
      toggleContainer.style.margin = "20px 0";
      toggleContainer.style.display = "flex";
      toggleContainer.style.gap = "15px";
      toggleContainer.style.flexWrap = "wrap";
      // Get current filter pattern to determine initial toggle states
      const fileNameFilterRegex = /\/\^(.*?) \\- \/i?\.matches\(file\.name\.lower\(\)\)/;
      const match = baseContent.match(fileNameFilterRegex);
      let currentFilters = [];
      if (match && match[1]) {
        currentFilters = match[1].replace(/[\(\)]/g, '').split('|');
      }
  
      // Create toggle function
      const createToggle = (label, value) => {
        const toggleWrapper = toggleContainer.createDiv();
        toggleWrapper.style.display = "flex";
        toggleWrapper.style.alignItems = "center";
        
        const checkbox = toggleWrapper.createEl("input", {
          type: "checkbox",
          attr: { id: `toggle-${value}` }
        });
        checkbox.checked = currentFilters.includes(value);
        
        const labelEl = toggleWrapper.createEl("label", {
          text: label,
          attr: { for: `toggle-${value}` }
        });
        labelEl.style.marginLeft = "5px";
        
        return checkbox;
      };
      
      // Create toggles dynamically based on ICONTYPES array
      const typeToggles = {};
      ICONTYPES.forEach(iconType => {
        typeToggles[iconType.pattern] = createToggle(iconType.name, iconType.pattern);
      });
  
      // Function to apply the filter
      const applyFilter = async () => {
        // Get selected file types
        const selectedTypes = [];
        ICONTYPES.forEach(iconType => {
          if (typeToggles[iconType.pattern].checked) selectedTypes.push(iconType.pattern);
        });
        // Build file type filter pattern
        const fileTypePattern = selectedTypes.length > 0 
          ? `/^(${selectedTypes.join('|')}) \\- /i`
          : `/^() \\- /i`; // Empty pattern if none selected
        
        // Get keyword filter
        const keywordTerm = input.value.trim() || ".";
        
        // Update both filter patterns in the base file
        let updatedContent = baseContent;
        
        // Update file name filter
        updatedContent = updatedContent.replace(
          /\/\^.*? \\\- \/i?\.matches\(file\.name\.lower\(\)\)/,
          `${fileTypePattern}.matches(file.name.lower())`
        );
        
        // Update keyword filter
        updatedContent = updatedContent.replace(
          /(- +)\/.*\/i?\.matches\(formula\.keywords\)/g,
          `$1/${keywordTerm}/i.matches(formula.keywords)`
        );
        
        // Save the updated file
        if (updatedContent !== baseContent) {
          await app.vault.modify(file, updatedContent);
          baseContent = updatedContent; // Update base content to prevent duplicate updates
        }
      };
      
      // -------------------
      // Add event listeners for input changes to apply filter immediately
      // -------------------
      contentEl.querySelectorAll("input").forEach(el => {
        el.addEventListener("input", applyFilter);
      });
      
      // Handle Enter key in the input field
      contentEl.querySelectorAll("input").forEach(el => {
        el.addEventListener("keydown", (e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            modal.close();
          }
        });
      });
      modal.open();
    },
  });
} else {
  new Notice("Icon Library not initialized. Please update to the latest Excalidraw Plugin version", 0);
}

// ------------------------
// ------------------------
// Excalidraw Event Hooks
// ------------------------
// ------------------------
/**
 * If set, this callback is triggered when the Excalidraw image is being exported to 
 * .svg, .png, or .excalidraw.
 * You can use this callback to customize the naming and path of the images. This allows
 * you to place images into an assets folder.
 * 
 * If the function returns null or undefined, the normal Excalidraw operation will continue
 * with the currentImageName and in the same folder as the Excalidraw file
 * If a filepath is returned, that will be used. Include the full Vault filepath and filename
 * with the file extension.
 * !!!! If an image already exists on the path, that will be overwritten. When returning
 * your own image path, you must take care of unique filenames (if that is a requirement) !!!!
 * The current image name is the name generated by Excalidraw:
 * - my-drawing.png
 * - my-drawing.svg
 * - my-drawing.excalidraw
 * - my-drawing.dark.svg
 * - my-drawing.light.svg
 * - my-drawing.dark.png
 * - my-drawing.light.png
 * 
 * @param data - An object containing the following properties:
 *   @property {string} exportFilepath - Default export filepath for the image.
 *   @property {string} excalidrawFile - TFile: The Excalidraw file being exported.
 *   @property {ExcalidrawAutomate} ea - The ExcalidrawAutomate instance associated with the hook.
 *   @property {string} [oldExcalidrawPath] - If action === "move" The old path of the Excalidraw file, else undefined
 *   @property {string} action - The action being performed: "export", "move", or "delete". move and delete reference the change to the Excalidraw  file.
 * 
 * @returns {string} - The new filepath for the image including full vault path and extension.
 * 
 * action === "move" || action === "delete" is only possible if "keep in sync" is enabled
 *   in plugin export settings
 *
 * Example usage:
 * ```
 * onImageFilePathHook: (data) => {
 *   const { currentImageName, drawingFilePath, frontmatter } = data;
 *   // Generate a new filepath based on the drawing file name and other criteria
 *   const ext = currentImageName.split('.').pop();
 *   if(frontmatter && frontmatter["my-custom-field"]) {
 *   }
 *   return `${drawingFileName} - ${currentImageName || 'image'}.${ext}`;
 * }
 * ```
 */
ea.onImageExportPathHook = (data) => {
  //debugger; //remove comment to debug using Developer Console

  let {
    excalidrawFile,
    exportFilepath,
    exportExtension,
    oldExcalidrawPath,
    action
  } = data;
  const frontmatter = app.metadataCache.getFileCache(excalidrawFile)?.frontmatter;
  //console.log(data, frontmatter);

  const excalidrawFilename = action === "move" ?
    ea.splitFolderAndFilename(excalidrawFile.name).filename :
    excalidrawFile.name

  if (excalidrawFilename.match(/^icon - /i)) {
    const {
      folderpath,
      filename,
      basename,
      extension
    } = ea.splitFolderAndFilename(exportFilepath);
    exportFilepath = "Assets/icons/" + filename;
    return exportFilepath;
  }

  if (excalidrawFilename.match(/^stickfigure - /i)) {
    const {
      folderpath,
      filename,
      basename,
      extension
    } = ea.splitFolderAndFilename(exportFilepath);
    exportFilepath = "Assets/stickfigures/" + filename;
    return exportFilepath;
  }

  if (excalidrawFilename.match(/^logo - /i)) {
    const {
      folderpath,
      filename,
      basename,
      extension
    } = ea.splitFolderAndFilename(exportFilepath);
    exportFilepath = "Assets/logos/" + filename;
    return exportFilepath;
  }


  // !!!! frontmatter will be undefined when action === "delete"
  // this means if you base your logic on frontmatter properties, then 
  // plugin settings keep files in sync will break for those files when
  // deleting the Excalidraw file. The images will not be deleted, or worst
  // your logic might result in deleting other files. This hook gives you
  // powerful control, but the hook function logic requires careful testing
  // on your part.
  //if(frontmatter && frontmatter["is-asset"]) { //custom frontmatter property
  //  exportFilepath = ea.obsidian.normalizePath("assets/" + exportFilepath);
  //  return exportFilepath;
  //}

  return exportFilepath;
};

/**
 * Excalidraw supports auto-export of Excalidraw files to .png, .svg, and .excalidraw formats.
 * 
 * Auto-export of Excalidraw files can be controlled at multiple levels.
 * 1) In plugin settings where you can set up default auto-export applicable to all your Excalidraw files.
 * 2) However, if you do not want to auto-export every file, you can also control auto-export
 *    at the file level using the 'excalidraw-autoexport' frontmatter property.
 * 3) This hook gives you an additional layer of control over the auto-export process.
 * 
 * This hook is triggered when an Excalidraw file is being saved.
 * 
 * interface AutoexportConfig {
 *   png: boolean; // Whether to auto-export to PNG
 *   svg: boolean; // Whether to auto-export to SVG
 *   excalidraw: boolean; // Whether to auto-export to Excalidraw format
 *   theme: "light" | "dark" | "both"; // The theme to use for the export
 * }
 *
 * @param {Object} data - The data for the hook.
 * @param {AutoexportConfig} data.autoexportConfig - The current autoexport configuration.
 * @param {TFile} data.excalidrawFile - The Excalidraw file being auto-exported.
 * @returns {AutoexportConfig | null} - Return a modified AutoexportConfig to override the export behavior, or null to use the default.
 */
ea.onTriggerAutoexportHook = (data) => {
  let {
    autoexportConfig,
    excalidrawFile
  } = data;
  //const frontmatter = app.metadataCache.getFileCache(excalidrawFile)?.frontmatter;
  //console.log(data, frontmatter);
  //logic based on filepath and frontmatter

  if (excalidrawFile.name.match(/^(?:icon|stickfigure|logo) - /i)) {
    autoexportConfig.theme = "light";
    autoexportConfig.svg = true;
    autoexportConfig.png = false;
    autoexportConfig.excalidraw = false;
    return autoexportConfig;
  }
  return autoexportConfig;
};
/*

*/

formulas:
Icon: image(file.path)
keywords: file.name.split(" - ")[1]
icon-path: link(if(file.ext == "md", "Assets/" + file.name.split(" - ")[0] + "s/" + file.name + ".svg", file.path))
views:
- type: cards
name: View
filters:
and:
- /^(icon|stickfigure|logo) \- /i.matches(file.name.lower())
- '!file.path.startsWith("Assets/")'
- /./i.matches(formula.keywords)
order:
- formula.keywords
sort:
- property: formula.keywords
direction: ASC
cardSize: 130
imageFit: contain
image: formula.icon-path
imageAspectRatio: 0.8
.bases-cards-cover {
background-color: #fff6f0b0;
}
.bases-cards-property.mod-title .bases-cards-line {
font-size: var(--font-ui-small);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment