DEVONthink

Automating DEVONthink 3

DEVONthink (DT) is a document management software with a companion app for i*OS. It has a comprehensive scripting dictionary which makes it attractive and easy to automate. Most publicly available examples are in AppleScript, the following scripts show how to achieve some goals with JXA.

Note that these script will currently not run as external scripts in DT’s smart rules. This is due to a broken JXA integration in version 3.7.2 and earlier. A fix is announced for a later version.

Rename records with regular expression #

DT comes with a script to rename selected records using a regular expression. It is based on the command line utility sed which tends to complicate things because of problems with quoting parameters and file names. The JXA script performs the same task without resorting to an external program. In addition, it shows the user the regular expression they entered in the first step when asking for the replacement string and displays the number of selected records.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
(() => {
  const app = Application("DEVONthink 3");
  app.includeStandardAdditions = true;

  const selection = app.selectedRecords();

  if (selection.length > 0) {
    const suffix = selection.length > 1 ? "s" : "";

    /* Set title to display # of selected records */

    const title = `Change name${suffix} for ${selection.length} record${suffix}`;

    /* Ask user for RegExp, stop if empty string */
    const searchFor = app.displayDialog("Search for RE", {
      withTitle: title,
      defaultAnswer: "",
    });
    if (searchFor.textReturned === "") return;

    /* Ask user for replacement, showing the RegExp they entered */
    const replaceWith = app.displayDialog(
      `Replace "${searchFor.textReturned}" by: (use $1, $2 etc. for groups)`,
      { withTitle: title, defaultAnswer: "" }
    );
    const re = new RegExp(searchFor.textReturned);
    const reText = replaceWith.textReturned;

    /* Change names of all selected records */
    selection.forEach((record) => {
      record.name = record.name().replace(re, reText);
    });
  } else {
    app.displayAlert("Please select at least one record.", {
      withTitle: title,
    });
  }
})();

You can see the script’s dialog boxes and result in the next three screen shots.

Asking the user for a regular expression Asking the user for the replacement string The changed record name

All Markdown processors use a user-defined CSS stylesheet if they see the appropriate definition at the top of the document, for example as a link element. The following script inserts the link to a CSS file at the top of the currently selected records. It does so by referring to the URL of a record in DT (cssURL). This document should of course contain the CSS definition you want to apply. You can get this URL by selecting “copy link” from the record’s context menu. Using a DT record as stylesheet is not strictly necessary, but it makes sure that DT can display your Markdown files in the way you want.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
(() => {
  /*
   * set cssURL to the x-devonhink-url of the CSS file you want to use. It must point
   * to a record in one of your DT databases.
   */
  const cssURL = "x-devonthink-item://0841105D-70B7-4518-8E2C-68E25CF8FC38";
  const app = Application("DEVONthink 3");
  const stylesheet = `<link rel="stylesheet" href="${cssURL}" />`;
  /*
   * Loop over all selected records that are of type Markdown
   */
  app.selectedRecords
    .whose({ _match: [ObjectSpecifier().type, "markdown"] })()
    .forEach((r) => {
      const src = r.plainText();
      r.plainText = `${stylesheet}\n\n${src}`;
    });
})();
DEVONthink 3 caches the style sheets, and I have no idea why. But that means that you have to restart DT in order to see changes to your style definitions.

Add table of content to Markdown records #

Post processors for MultiMarkdown, the dialect supported by DT, generate a table of content if they see {{TOC}} in the Markdown document. The next script adds this marker to all currently selected Markdown records.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
(() => {
  const app = Application("DEVONthink 3");
  /*
   * Regular expression to find
   * at least one # sign at the start of a line,
   * followed by a space
   */
  const headline = new RegExp("^#+ ", "m");
  /*
   * Loop over all selected Markdown records
   */
  app.selectedRecords
    .whose({ _match: [ObjectSpecifier().type, "markdown"] })()
    .forEach((r) => {
      const src = r.plainText();
      /* Find first headline in document */
      const found = src.match(headline);
      if (found) {
        /*
         *  Start position of first headline in text
         */
        const position = found.index;
        /*
         * Rebuild the text from
         *  - first part of it (before the first '#')
         *  - {{TOC}} marker
         *  - rest of the text (from the first '#') to the end
         */
        r.plainText = `${src.substring(
          0,
          position
        )}\n\n{{TOC}}\n\n${src.substring(position)}`;
      }
    });
})();

This is a bit more complicated than the previous example, because the TOC marker has to be inserted right before the first headline. This is not a Markdown requirement but since you don’t know if there’s something else before the first headline that must stay at its place (e.g. the link to a CSS file), it is safer to put the TOC right before the first headline.

To do so, the script has to split the original text at the position of the first headline and reassemble it by adding the TOC marker between the two parts. Alternatively, you could use replace like so: src.replace(found[0],`${found[0]\n\n{{TOC}}\n\n`);

Copy hashtags to DT tags #

This issue arose in the DT user forum: Someone had hashtags embedded in their Markdown records and wanted to copy them to the record’s DT tags. That is what the next script does for hashtags like “#xxx”. Those can not be confused with Markdown headlines which look like “# xxx”: there’s a blank between the pound sign and the text.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(() => {
  const app = Application("DEVONthink 3");
  app.selectedRecords
    .whose({ _match: [ObjectSpecifier().type, "markdown"] })()
    .forEach((r) => {
      /*
       * Find hashtags in text.
       * matches contains array for each match:
       * [ ["#hash1", "hash1", index1 …]
       *   ["#hash2", "hash2", index2 …]
       * …
       * ]
       */
      const matches = [...r.plainText().matchAll(/#([^# ]+)/gm)];
      /*
       * Build array of hashtags
       */
      const newTags = matches.map((m) => m[1]);
      /*
       * Merge old and new tags and set newTags
       */
      r.tags = [...newTags, ...r.tags()];
    });
})();

The script loops over all currently selected Markdown records. For each of them, it finds all hashtags, i.e. strings beginning with “#” and followed by anything that is neither a blank nor a “#” sign. It then extracts the tags proper, i.e. the part after the “#” into the array newTags and merges this with the old tags (r.tags()), using JavaScript’s spread syntax (...). Finally, it assigns this new array to the record’s tags property.

You do not have to worry about assigning the same tag twice: DT takes care of that internally, so even if the new tags contain one or more of the already existing tags for this record, all of them will appear only once.

Add tags from CSV file to records #

You can also store tags to add to DT records in an external file, for example in CSV format. that means “comma separated value”, though this is something of a misnomer nowadays: you can use tabs as well as semicolons as a separator between fields, too. These files can be exported by spread sheet programs like Excel, Numbers and Open/LibreOffice Calc.

Suppose your original data looks like this in a spread sheet program:

Name Tags
first name tag1, tag2, tag3
second name tag4, tag5

So the name of the record is stored in the first column, the tags to assign to it in the second one.

You can import CSV files directly into DT where they are aptly called “sheets”. To access the table data itself, you use the cells property which contains an array of rows, each of which is again a row with the cells.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(() => {
  const docUUID = "3814468E-F9C7-4332-80CA-B42C8A321926";
  const app = Application("DEVONthink 3");
  app.includeStandardAdditions = true;
  const db = app.currentDatabase();
  const tableRecord = app.getRecordWithUuid(docUUID);
  /* 
   * Get the cells from the sheet record
   * cells = [ * first row *      [column1, column2]
               * secondnd row *  [column1, column2]
             ]
   */

  const cells = tableRecord.cells();

  cells.forEach((row, i) => {
    const name = row[0],
      tags = row[1].split(",");
    const record = db.contents[name];
    try {
      record.tags = [...record.tags(), ...tags];
    } catch (Error) {
      app.displayAlert(`Record "${name}" not found on line ${i + 1}.`, {
        withTitle: "Set tags from sheet",
      });
    }
  });
})();

If there’s an error in the record’s name, the script will run into an error. It tries to catch this, but there’s no possibility to reliably get the reason for the error. So the script simply assumes that a wrong record name caused it and displays the name as well as the line in the sheet document.

Create archive from selected records #

If you need to send DT records to someone else, it might be useful to pack them in a ZIP archive: they remain together and are compressed, so take less room when sending an storing. That’s what the next script does: It takes the selected records and packs them in an archive, whose name the user chooses interactively. The records are stored with their bare name only, not their full path on the disk. That makes it easier to unpack them later to another location.

This script uses a simple regular expression to protect special characters like quotes and spaces in file names. It is equivalent to AppleScript’s quoted form of… command: filename.replaceAll(/'/g, "'\\''")

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
(() => {
  const app = Application("DEVONthink 3");
  app.includeStandardAdditions = true;
  const dialogTitle = "Create archive from records";
  /*
   * curApp needed to use doShellScript
   */
  const curApp= Application.currentApplication();
  curApp.includeStandardAdditions = true;

  /*
   * Get the paths of the selected records
   */
  const paths = app.selectedRecords.path();

  if (paths.length === 0 ) {
    app.displayAlert("Please select at least one record",
      {withTitle: dialogTitle});
    return;
  }
/*
 * Ask user for target archive name
 */
  const suffix = paths.length > 1 ? "s" : ""
  let zipPath = app.chooseFileName({
      withPrompt: `Save ${paths.length} file${suffix} in archive at:`,
      withTitle: dialogTitle
  });
  if (!zipPath || zipPath === "") {
    app.displayAlert("Archive name must not be empty", 
      {withTitle: dialogTitle})
  }
  /* 
   * Append .zip extension if necessary
   */
  if (! /\.zip$/.test(zipPath)) {
    zipPath += ".zip";
  }
  /*
   * Quote all special characters, equivalent to
   * AppleScript's "quoted form" for strings
   */
  const pathList = paths.map(p => {
    return `'${p.replaceAll(/'/g, "'\\''")}'`;
  });

  /*
   * Build and execute zip command for shell
   */
  const zipCommand = `zip -j '${zipPath}' ${pathList.join(' ')}`;
  curApp.doShellScript(zipCommand);
})()
About