Script: Convert Markdown to TextBundle with images included

Since people here are occasionally asking for TextBundle support, I whipped up a Javascript script that converts the selected Markdown records to TextBundle packages in the file system (not in DT, since DT doesn’t support TextBundle natively). It copies all local images (i.e. those not referred to by http/s URLs) into the TextBundle’s asset folder.

The code is only minimally tested with a single MD document, containing several images. Bear could import the resulting TextBundle just fine. Note that the TextBundle will not display an icon!

I tried to comment the script as detailed as possible. Please don’t hesitate to ask any remaining questions here.

Also note that the code relies on the existence of the SetFile system command. It is part of the XCode command line tools. If you don’t have a usable SetFile installed, please install the XCode command line tools. See this StackExchange thread for details.

JavaScript code to convert a Markdown document into a TextBundle package
const linkMap = {};
/* Debug flag.
   Set to 1 to print out debug message (when run in Script Editor or with osascript)
*/
const debug = 0;  
/* "Manifest" data, will be written to "info.json" in the text bundle */
const manifest = {
  version: 2,
  type: "net.daringfireball.markdown",
};

(() => {
  const app = Application("DEVONthink 3");
  const curApp = Application.currentApplication();
  curApp.includeStandardAdditions = true;
  /* Define the base folder to place the text bundles in. 
     Here, it is "~/Documents/textBundles". 
     Change at will.
  */
  const documentsDir = Application("System Events").documentsFolder.posixPath();
  const textBundleDir = `${documentsDir}/textBundles`;
  curApp.doShellScript(`mkdir -p ${textBundleDir}`);
  debug && console.log(`Target directory for textbundle: "${textBundleDir}"`);
  
  /* Loop over all selected markdown records */
  const records = app.selectedRecords();
  records.filter(r => r.type() === 'markdown').forEach(r => {

    /* get the text of the MD record and its name */
    let txt = r.plainText();
    const name = r.name();
    
    /* get links to all images, ignoring http/https ones */
    const imageLinks = [...txt.matchAll(/!\[.*\]\((.+)\)/g)]
    .filter(m => !/^https?:\/\//.test(m[1]))
    .map(m => m[1]);
    debug && console.log(`Handling links "${imageLinks}"`);
    
    /* create folders for this DT record under `textBundleDir` 
       naming it after the record
    */
    const targetDir = `${textBundleDir}/${name}.textbundle`;
    const assetDir = `${targetDir}/assets`;
    debug && console.log(`assetDir ${assetDir}`);
    curApp.doShellScript(`mkdir -p "${assetDir}"`);
    
    /* Set the bundle bit for the target folder */
    curApp.doShellScript(`SetFile -a BE "${targetDir}"`);
    
    /* copy all images to the assets directory */
    imageLinks.forEach(l => copyImage(r, l, assetDir, app, curApp))
    
    /* replace all links in the record's text 
      with links to the images in the assets directory.
      Note: this doesn't modify the original record!
    */
    Object.keys(linkMap).forEach(srcLink => {
      const targetLink = linkMap[srcLink];
      const replaceRE = new RegExp(`(!\\[.*?\\]\\()${srcLink}`,'g');
      txt = txt.replaceAll(replaceRE, `$1assets/${targetLink}`);
    })
    debug && console.log(txt);
    
    /* Write the text to file "text.md" */
    const txtFile = curApp.openForAccess(Path(`${targetDir}/text.md`), {writePermission: true});
    curApp.write(txt, {to: txtFile, startingAt: 0});
    curApp.closeAccess(txtFile);
    
    /* Write the manifest to file "info.js" */
    manifest.sourceURL = `x-devonthink-item://${r.uuid()}`;
    const infoFile = curApp.openForAccess(Path(`${targetDir}/info.json`), {writePermission: true});
    curApp.write(JSON.stringify(manifest), {to: infoFile, startingAt: 0});
    curApp.closeAccess(infoFile);
  })
})()

/* Function to copy an image found in the MD document 
   to the "assets" folder of the text bundle.
   Handles these cases:
   - URL is a DT itemlink
   - absolute URLs like "/images/..."
   - filesystem URLs like "file://..."
   - relative URLs like "images/..."
   In all cases, the variables
     sourcePath will be set to the filesystem path of the image
     sourceName will be set to the filesystem _name_ of the image, including extension
   These two are used in the end to determine the source and target of the "cp" operation
*/
function copyImage(record, link, dir, dt, curApp) {
  debug && console.log(`copyImage: link - ${link}`);
  let sourcePath = record.path(); // path of the image in the filesystem
  let sourceName = "";  // name 
  let sourceGroup = null; // the DT group storing the image for absolute and relative URLs
  
  if (/^x-devonthink-item:/.test(link)) {
    /* DT item link: copy record to assets dir  */
    const imageRecord = dt.getRecordWithUuid(link);
    if (imageRecord) {
      sourcePath = imageRecord.path();
      sourceName = fileFromPosixPath(sourcePath);
    }
  } else if (link.startsWith("/")) {
    /* absolute image reference - same database, different group */
    sourceGroup = dt.createLocation(pathFromPosixPath(link), {in: record.dataBase()});
  } else if (link.startsWith("file://")) {
    /* Image in filesystem */
    sourcePath = decodeURI(link.replace("file://",''));
    sourceName = decodeURI(link.replace(/.*\/(.*)/,"$1"));
  } else {
    /* relative image */
    sourceGroup = link.includes('/') ? 
      dt.createLocation(pathFromPosixPath(link),{in: record.database()}):
      record.parents[0]
     ;
  }
  /* If sourceGroup is set, we're dealing with an image _inside_ DT, either
     absolutely or relatively referenced. 
     sourceBroup is then the _group_ the image is located in, same database as the current record
     The code inside the "if" uses the "search" method to find the correct record in this group.
     DT allows referring to images with and without file extension, so "images/image" and "images/image.png" 
     might be used in a MD file. 
     If an extension is present, the code searches for "filename", otherwise for "name". 
  */
  if (sourceGroup) {
    debug && console.log(`  copyImage: sourceGroup - ${sourceGroup.uuid()} record's database ${record.database.uuid()}`);
    const imageName = fileFromPosixPath(link);
    /* Build the search expression depending on the presence of a file extension */
    const predicate = imageName.includes('.') ? 
      `filename:${imageName}`:
      `name: ${imageName}`;
    /* Search for the image either in the database (if the sourceGroup is the same as the database) 
       or in the "sourceGroup" 
    */
    const sourceImage = (() => {
      if (sourceGroup.uuid() !== record.database.uuid()) {
        debug && console.log(`  searching "${predicate}" in ${sourceGroup.name()}`);
        return dt.search(predicate, {in:sourceGroup, excludeSubgroups : true});
      } else {
        debug && console.log(`  searching "${predicate}" in database`);
        return dt.search(predicate);
      }
      })();
      
    debug && console.log(`  copyImage: found ${sourceImage.length} matches for "${imageName}"`);
    /* Set sourcePath and sourceName if a record is found */
    if (sourceImage && sourceImage.length > 0) {
      const image = sourceImage[0];
      sourcePath = image.path();
      sourceName = fileFromPosixPath(sourcePath);
    }
  }
  debug && console.log(`copyImage: link - ${link}, sourcePath - "${sourcePath}" sourceName - "${sourceName}"`);
  /* 
    If "sourceName" is not empty, we've found a matching image. 
    Save its name in "linkMap" for this link and copy it to the 
    bundle's "assets" directory
  */
  if (sourceName !== "") {
    linkMap[link] = encodeURI(sourceName);
    curApp.doShellScript(`cp "${sourcePath}" "${dir}/${sourceName}"`);
  }
  
}

/* Return the last element from a Posix path, namely the filename */
function fileFromPosixPath(path) {
  return path.split('/').pop();
}

/* Return a Posix path with the last element shopped off – that's just
   the path without the filename */
function pathFromPosixPath(path) {
  const components = path.split('/');
  components.pop();
  return components.join('/');
}
5 Likes