Jump to content

/home/alex/dev/align-projects.jxa (1)

From RaySoft
#!/usr/bin/osascript -l JavaScript
// jshint esversion: 6
// -----------------------------------------------------------------------------
// align-projects.jxa
// ==================
//
// Scope     macOS
// Copyright (C) 2025 by RaySoft, Zurich, Switzerland
// License   GNU General Public License (GPL) 2.0
//           https://www.gnu.org/licenses/gpl2.txt
//
// This script gets all projects available in OmniFocus, Obsidian, Mail and
// Filesystem. Than it tries to align all information by project name.
//
// -----------------------------------------------------------------------------
// Configuration
// -------------

// Absolute path to the configuration file
CONFIG_FILE = '/Users/alex/dev/align-projects.json';

// -----------------------------------------------------------------------------
// Filesystem functions
// --------------------

/**
 * Parse the configured list of Filesystem folders
 * @param  {list of objects} projectObjs       List of project objects
 * @param  {list of strings} fsRootFolderPaths List of all root folder paths
 * @param  {app handler}     seHandler         System Event application handler
 * @return {bool}                              Function exit status
 */
const parseFsFolders = (projectObjs, fsRootFolderPaths, seHandler) => {
  for (const fsRootFolderPath of fsRootFolderPaths) {
    const fsRootFolderObj = seHandler.folders.byName(fsRootFolderPath);

    if (! fsRootFolderObj.exists()) {
      console.log('Error finding Filesystem folder:', fsRootFolderPath);

      $.exit(1);
    }

    // console.log('Debug parseFsFolders:', fsRootFolderPath); // Debug

    getFsFolders(projectObjs, fsRootFolderPaths, fsRootFolderObj);
  }
};

/**
 * Get the information of all Filesystem folders within a given root folder and
 * store the folders' data
 * @param  {list of objects} projectObjs       List of project objects
 * @param  {list of strings} fsRootFolderPaths List of all root folder paths
 * @param  {object}          fsRootFolderObj   A root folder object
 * @return {bool}                              Function exit status
 */
const getFsFolders = (projectObjs, fsRootFolderPaths, fsRootFolderObj) => {
  for (var i = 0; i < fsRootFolderObj.folders.length; i++) {
    const fsFolderObj = fsRootFolderObj.folders[i];

    if (/^zz_/i.test(fsFolderObj.name())) {
      continue;
    }

    const name = fsFolderObj.name();
    const path = decodeURI(fsFolderObj.url().replace(/file:\/\/(.+)\/$/, '$1'));
    const id = makeProjectId(name);

    // console.log('Debug getFsFolders:', name); // Debug

    if (fsRootFolderPaths.includes(path)) {
      continue;
    }

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].fsFolderExists = true;
    projectObjs[id].fsFolderPath = path.replace(
        /^\/(home|Users)\/[^/]+/, '${HOME}'
    );
    projectObjs[id].fsFolderUrl = fsFolderObj.url();
  }
};

// -----------------------------------------------------------------------------
// Mail functions
// --------------

/**
 * Parse the configured list of Mail accounts
 * @param  {list of objects} projectObjs     List of project objects
 * @param  {list of objects} mailAccountObjs List of account objects
 * @param  {app handler}     mailHandler     Mail application handler
 * @return {bool}                            Function exit status
 */
const parseMailAccounts = (projectObjs, mailAccountObjs, mailHandler) => {
  for (const mailAccountObj of mailAccountObjs) {
    if (! mailHandler.accounts.byName(mailAccountObj.name).exists()) {
      console.log('Error finding Mail account:', mailAccountObj.name);

      $.exit(1);
    }

    // console.log('Debug parseMailAccounts:', mailAccountObj.name); // Debug

    parseMailBoxes(projectObjs, mailAccountObj, mailHandler);
  }
};

/**
 * Parse the configured list of Mail mailboxes within a given account
 * @param  {list of objects} projectObjs    List of project objects
 * @param  {object}          mailAccountObj An account object
 * @param  {app handler}     mailHandler    Mail application handler
 * @return {bool}                           Function exit status
 */
const parseMailBoxes = (projectObjs, mailAccountObj, mailHandler) => {
  const mailRootBoxPaths = mailAccountObj.mailboxes;

  for (const mailRootBoxPath of mailRootBoxPaths) {
    const mailRootBoxObj = mailHandler.accounts.byName(mailAccountObj.name)
                                      .mailboxes.byName(mailRootBoxPath);

    if (! mailRootBoxObj.exists()) {
      console.log('Error finding Mail mailbox:', mailRootBoxPath);

      $.exit(1);
    }

    // console.log('Debug parseMailBoxes:', mailRootBoxPath); // Debug

    getMailBox(projectObjs, mailRootBoxPaths, mailRootBoxPath, mailRootBoxObj);
  }
};

/**
 * Get the information of all Mail mailboxes within a given root mailbox and
 * store the mailboxes' data
 * @param  {list of objects} projectObjs      List of project objects
 * @param  {list of strings} mailRootBoxPaths List of root mailbox paths
 * @param  {string}          mailRootBoxPath  A root mailbox path
 * @param  {object}          mailRootBoxObj   A root mailbox object
 * @return {bool}                             Function exit status
 */
const getMailBox = (projectObjs, mailRootBoxPaths, mailRootBoxPath,
                    mailRootBoxObj) => {
  for (var i = 0; i < mailRootBoxObj.mailboxes.length; i++) {
    const mailBoxObj = mailRootBoxObj.mailboxes[i];

    const name = mailBoxObj.name();
    const path = [mailRootBoxPath, name].join('/');
    const id = makeProjectId(name);

    // console.log('Debug getMailBox:', path); // Debug

    if (mailRootBoxPaths.includes(path)) {
      continue;
    }

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].mailBoxExists = true;
    projectObjs[id].mailBoxAccount = mailBoxObj.account.name();
    projectObjs[id].mailBoxPath = path;
  }
};

// -----------------------------------------------------------------------------
// Obsidian functions
// ------------------

/**
 * Parse the configured list of Obsidian vaults
 * @param  {list of objects} projectObjs List of project objects
 * @param  {list of objects} obVaultObjs List of vault objects
 * @param  {app handler}     seHandler   System Event application handler
 * @return {bool}                        Function exit status
 */
const parseObVaults = (projectObjs, obVaultObjs, seHandler) => {
  for (var obVaultObj of obVaultObjs) {
    obVaultObj.path = [obVaultObj.base, obVaultObj.name].join('/');

    if (! seHandler.folders.byName(obVaultObj.path).exists()) {
      console.log('Error finding Obsidian vault:', obVaultObj.path);

      $.exit(1);
    }

    // console.log('Debug parseObVaults:', obVaultObj.path); // Debug

    parseObFolders(projectObjs, obVaultObj, seHandler);
  }
};

/**
 * Parse the configured list of Obsidian folders within a given vault
 * @param  {list of objects} projectObjs List of project objects
 * @param  {object}          obVaultObj  A vault object
 * @param  {app handler}     seHandler   System Event application handler
 * @return {bool}                        Function exit status
 */
const parseObFolders = (projectObjs, obVaultObj, seHandler) => {
  for (const obRootFolderName of obVaultObj.folders) {
    var obRootFolderObj = {
        vault: obVaultObj.name,
        path: [obVaultObj.path, obRootFolderName].join('/'),
        name: obRootFolderName,
    };

    if (! seHandler.folders.byName(obRootFolderObj.path).exists()) {
      console.log('Error finding Obsidian folder:', obRootFolderObj.path);

      $.exit(1);
    }

    // console.log('Debug parseObFolders:', obRootFolderObj.path); // Debug

    obRootFolderObj.files = seHandler.folders.byName(obRootFolderObj.path)
                                     .files;

    getObFiles(projectObjs, obRootFolderObj);

    obRootFolderObj.folders = seHandler.folders.byName(obRootFolderObj.path)
                                       .folders;

    getObFolders(projectObjs, obRootFolderObj);
  }
};

/**
 * Get the information of all Obsidian files within a given folder and store
 * the files' data
 * @param  {list of objects} projectObjs     List of project objects
 * @param  {object}          obRootFolderObj A folder object
 * @return {bool}                            Function exit status
 */
const getObFiles = (projectObjs, obRootFolderObj) => {
  for (var i = 0; i < obRootFolderObj.files.length; i++) {
    const obFileObj = obRootFolderObj.files[i];

    if (
        ! /\.md$/i.test(obFileObj.name()) ||
        /^(-|[0-9]+) +/.test(obFileObj.name())
    ) {
      continue;
    }

    const name = obFileObj.name().replace(/\.md$/, '');
    const path = [obRootFolderObj.name, name].join('/');
    const id = makeProjectId(name);

    // console.log('Debug getObFiles:', path); // Debug

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].obFileExists = true;
    projectObjs[id].obFileVault = obRootFolderObj.vault;
    projectObjs[id].obFilePath = path + '.md';
    projectObjs[id].obFileUrl = 'obsidian://open' +
                                '?vault=' + encodeURI(obRootFolderObj.vault) +
                                '&file=' + encodeURIComponent(path);
  }
};

/**
 * Get the information of all Obsidian folders within a given folder and store
 * the folders' data
 * @param  {list of objects} projectObjs     List of project objects
 * @param  {object}          obRootFolderObj A folder object
 * @return {bool}                            Function exit status
 */
const getObFolders = (projectObjs, obRootFolderObj) => {
  for (var i = 0; i < obRootFolderObj.folders.length; i++) {
    const obFolderObj = obRootFolderObj.folders[i];

    if (/^(archiv|attachments)$/i.test(obFolderObj.name())) {
      continue;
    }

    const name = obFolderObj.name();
    const path = [obRootFolderObj.name, name].join('/');
    const id = makeProjectId(name);

    // console.log('Debug getObFolders:', path); // Debug

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].obFolderExists = true;
    projectObjs[id].obFolderVault = obRootFolderObj.vault;
    projectObjs[id].obFolderPath = path;
  }
};

// -----------------------------------------------------------------------------
// OmniFocus functions
// -------------------

/**
 * Parse the configured list of OmniFocus containers (folders or projects)
 * @param  {list of objects} projectObjs      List of project objects
 * @param  {list of strings} ofContainerPaths List of container paths
 * @param  {app handler}     ofHandler        OmniFocus application handler
 * @return {bool}                             Function exit status
 */
const parseOfContainers = (projectObjs, ofContainerPaths, ofHandler) => {
  loopContainerPaths:
  for (const ofContainerPath of ofContainerPaths) {
    const pathSections = ofContainerPath.split('/');

    var ofFolderObj = ofHandler.defaultDocument;
    var ofProjectObj = '';

    loopPathSections:
    for (var i = 0; i < pathSections.length; i++) {
      const currentPath = pathSections.slice(i).join('/');

      var oldFolderObj = ofFolderObj;

      ofFolderObj = oldFolderObj.folders.byName(pathSections[i]);
      ofProjectObj = oldFolderObj.projects.byName(pathSections[i]);

      if (ofFolderObj.exists()) {
        if (ofFolderObj.hidden()) {
          console.log('OmniFocus folder is hidden:', currentPath);

          continue loopContainerPaths;
        }

        // console.log('Debug parseOfContainers:', currentPath); // Debug

        if (i === pathSections.length - 1) {
          getOfProjects(projectObjs, ofFolderObj, ofContainerPath);
        }
      }
      else if (ofProjectObj.exists()) {
        if (ofProjectObj.completed() || ofProjectObj.dropped()) {
          console.log(
              'OmniFocus project is completed or dropped:', currentPath
          );

          continue loopContainerPaths;
        }

        // console.log('Debug parseOfContainers:', currentPath); // Debug

        getOfTasks(projectObjs, ofProjectObj, ofContainerPath);
      }
      else {
        console.log('Error finding OmniFocus container:', currentPath);

        $.exit(1);
      }
    }
  }
};

/**
 * Get the information of all OmniFocus projects within a given folder and
 * store the  projects' data
 * @param  {list of objects} projectObjs  List of project objects
 * @param  {object}          ofFolderObj  A folder object
 * @param  {string}          ofFolderPath A folder path
 * @return {bool}                         Function exit status
 */
const getOfProjects = (projectObjs, ofFolderObj, ofFolderPath) => {
  for (var i = 0; i < ofFolderObj.projects.length; i++) {
    const ofProjectObj = ofFolderObj.projects[i];

    if (ofProjectObj.completed() || ofProjectObj.dropped()) {
      continue;
    }

    const name = ofProjectObj.rootTask.name();
    const path = [ofFolderPath, name].join('/');
    const id = makeProjectId(name);

    // console.log('Debug getOfProjects:', path); // Debug

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].ofProjectExists = true;
    projectObjs[id].ofProjectPath = path;
    projectObjs[id].ofProjectUrl = makeOfTaskUrl(ofProjectObj.rootTask.id());

    appendUrlsToOfNote(projectObjs[id], ofProjectObj.rootTask);
  }
};

/**
 * Get the information of all OmniFocus tasks within a given project and store
 * the tasks' data
 * @param  {list of objects} projectObjs   List of project objects
 * @param  {object}          ofProjectObj  A project object
 * @param  {string}          ofProjectPath A project path
 * @return {bool}                          Function exit status
 */
const getOfTasks = (projectObjs, ofProjectObj, ofProjectPath) => {
  for (var i = 0; i < ofProjectObj.tasks.length; i++) {
    const ofTaskObj = ofProjectObj.tasks[i];

    if (ofTaskObj.completed() || ofTaskObj.dropped()) {
      continue;
    }

    const name = ofTaskObj.name();
    const path = [ofProjectPath, name].join('/');
    const id = makeProjectId(name);

    // console.log('Debug getOfTasks:', path); // Debug

    setProjectDefaultValues(projectObjs, id, name);

    projectObjs[id].ofTaskExists = true;
    projectObjs[id].ofTaskPath = path;
    projectObjs[id].ofTaskUrl = makeOfTaskUrl(ofTaskObj.id());

    appendUrlsToOfNote(projectObjs[id], ofTaskObj);
  }
};

// -----

/**
 * Make an OmniFocus URL from an task ID
 * @param  {string} ofTaskId A task ID
 * @return {string}          A task URL
 */
const makeOfTaskUrl = (ofTaskId) => {
  return 'omnifocus:///task/' + encodeURI(ofTaskId);
};

/**
 * Append the project's Filesystem and Obsidian URLs into an OmniFocus note
 * @param  {object} projectObj A project object
 * @param  {object} ofTaskObj  A task object
 * @return {bool}              Function exit status
 */
const appendUrlsToOfNote = (projectObj, ofTaskObj) => {
  if (projectObj.fsFolderExists && ! /file:\/\//.test(ofTaskObj.note())) {
    ofTaskObj.note = [ofTaskObj.note(), projectObj.fsFolderUrl].join('\n');
  }

  if (projectObj.obFileExists && ! /obsidian:\/\//.test(ofTaskObj.note())) {
    ofTaskObj.note = [ofTaskObj.note(), projectObj.obFileUrl].join('\n');
  }
};

// -----------------------------------------------------------------------------
// Helper functions
// ----------------

/**
 * Make a project ID from a name
 * @param  {string} name A project name
 * @return {string}      A project ID
 */
const makeProjectId = (name) => {
  return name.trim().normalize('NFC').replace(/["']/g, '').replace(/ /g, '_')
             .toLowerCase();
};

/**
 * Set a entry in the list of object 'projectObjs' based on the project ID
 * and set required default values
 * @param  {list of objects} projectObjs List of project objects
 * @param  {string}          id          A project ID
 * @param  {string}          name        A project name
 * @return {bool}                        Function exit status
 */
const setProjectDefaultValues = (projectObjs, id, name) => {
  name = name.trim().normalize('NFC');

  if (! projectObjs.hasOwnProperty(id)) {
    projectObjs[id] = {
        name: name,
        fsFolderExists: false,
        mailBoxExists: false,
        obFileExists: false,
        obFolderExists: false,
        ofProjectExists: false,
        ofTaskExists: false,
    };
  }
  else {
    if (projectObjs[id].name != name) {
      projectObjs[id].name = name;
    }
  }
};

// -----------------------------------------------------------------------------
// Main
// ----

(() => {
  'use strict';

  // Load Objective-C or C libraries
  ObjC.import('Foundation');
  ObjC.import('stdlib');


  // Create an Objective-C File Manager object
  const fileManager = $.NSFileManager.defaultManager;

  // Test if the configuration file exists otherwise end the program
  if (! fileManager.isReadableFileAtPath(CONFIG_FILE)) {
    console.log('Error finding configuration file:', CONFIG_FILE);

    $.exit(1);
  }

  // Store the configuration file content using the given encoding
  const configStr = $.NSString.alloc.initWithDataEncoding(
                        fileManager.contentsAtPath(CONFIG_FILE),
                        $.NSUTF8StringEncoding
                    );

  // Parse the configuration file content as JSON
  const configJSON = JSON.parse(ObjC.unwrap(configStr));


  // Create a 'Mail' application handler
  const mailHandler = Application('Mail');
  mailHandler.includeStandardAdditions = true;

  // Create an 'OmniFocus' application handler
  const ofHandler = Application('OmniFocus');
  ofHandler.includeStandardAdditions = true;

  // Create a 'System Events' application handler
  const seHandler = Application('System Events');
  seHandler.includeStandardAdditions = true;


  // Create a dictionary object to store all project data
  const projectObjs = {};

  // WARNING: The 'parseFsFolders' function must be called before the
  //          'parseOfContainers' function
  parseFsFolders(projectObjs, configJSON.filesystem, seHandler);

  parseMailAccounts(projectObjs, configJSON.mail, mailHandler);

  // WARNING: This 'parseObVaults' function must be called before the
  //          'parseOfContainers' function
  parseObVaults(projectObjs, configJSON.obsidian, seHandler);

  parseOfContainers(projectObjs, configJSON.omnifocus.folders, ofHandler);

  parseOfContainers(projectObjs, configJSON.omnifocus.projects, ofHandler);


  // Evaluate the collected data for each project
  for (const [id, projectObj] of Object.entries(projectObjs).sort()) {
    // Print the project's name
    console.log('\n' + projectObj.name);

    // Evaluate the OmniFocus related data
    if (projectObj.ofProjectExists && projectObj.ofTaskExists) {
      console.log('‼️ Error more than one OmniFocus project found ‼️');
    }
    else if (projectObj.ofProjectExists) {
      console.log(
          '- OmniFocus project\n' +
          '  - Path:   ', projectObj.ofProjectPath + '\n' +
          '  - URL:    ', projectObj.ofProjectUrl
      );
    }
    else if (projectObj.ofTaskExists) {
      console.log(
          '- OmniFocus task\n' +
          '  - Path:   ', projectObj.ofTaskPath + '\n' +
          '  - URL:    ', projectObj.ofTaskUrl
      );
    }
    else {
      console.log('‼️ No OmniFocus project found ‼️');
    }

    // Evaluate the Filesystem related data
    if (projectObj.fsFolderExists) {
      console.log(
          '- Filesystem folder\n' +
          '  - Path:   ', projectObj.fsFolderPath + '\n' +
          '  - URL:    ', projectObj.fsFolderUrl
      );
    }

    // Evaluate the Mail related data
    if (projectObj.mailBoxExists) {
      console.log(
          '- Mail mailbox\n' +
          '  - Account:', projectObj.mailBoxAccount + '\n' +
          '  - Path:   ', projectObj.mailBoxPath
      );
    }

    // Evaluate the Obsidian related data
    if (projectObj.obFileExists) {
      console.log(
          '- Obsidian file\n' +
          '  - Vault:  ', projectObj.obFileVault + '\n' +
          '  - Path:   ', projectObj.obFilePath + '\n' +
          '  - URL:    ', projectObj.obFileUrl
      );
    }

    if (projectObj.obFolderExists) {
      if (! projectObj.obFileExists) {
        console.log('‼️ No Obsidian file found ‼️');
      }

      console.log(
          '- Obsidian folder\n' +
          '  - Vault:  ', projectObj.obFolderVault + '\n' +
          '  - Path:   ', projectObj.obFolderPath
      );
    }
  }

  $.exit(0);
})();

// -----------------------------------------------------------------------------

Usage

Create a configuration file
vi "${HOME}/dev/align-projects.json"
{
  "filesystem": [
    "/Users/alex/Documents/12-projects",
    "/Users/alex/Documents/12-projects/mountaineering",
    "/Users/alex/Documents/12-projects/travel"
  ],
  "mail": [
    {
      "name": "alex@raysoft.loc",
      "mailboxes": [
        "Projects",
        "Projects/Mountaineering",
        "Projects/Travel"
      ]
    }
  ],
  "obsidian": [
    {
      "name": "Main",
      "base": "/Users/alex/Obsidian",
      "folders": [
        "12 Projects",
        "12 Projects/Mountaineering",
        "12 Projects/Travel"
      ]
    }
  ],
  "omnifocus": {
    "folders": [
      "Active projects"
    ],
    "projects": [
      "Maybe / sometimes projects"
    ]
  }
}
Change the script's permissions
chmod 0700 "${HOME}/dev/align-projects.jxa"
Run the script
"${HOME}/dev/align-projects.jxa"