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

From RaySoft
#!/usr/bin/osascript -l JavaScript
// jshint esversion: 6
// -----------------------------------------------------------------------------
// align-projects.jxa
// ==================
//
// Scope     macOS
// Copyright (C) 2024 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 a given list of Filesystem folders
 * @param  {list of objects} projectObjs       List of all project objects
 * @param  {list of strings} fsRootFolderPaths List of all File System root paths
 * @param  {app handler}     seHandler         System Event 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 a Filesystem folder and store this data
 * @param  {list of objects} projectObjs       List of all project objects
 * @param  {list of strings} fsRootFolderPaths List of all File System root paths
 * @param  {object}          fsRootFolderObj   File System 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_/.test(fsFolderObj.name())) {
      continue;
    }

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

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

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

    setProjectDefaults(projectObjs, id, name);

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

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

/**
 * Parse a given list of Mail accounts
 * @param  {list of objects} projectObjs     List of all project objects
 * @param  {list of objects} mailAccountObjs List of Mail 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 a given list of Mail mailboxes within an account
 * @param  {list of objects} projectObjs    List of all project objects
 * @param  {object}          mailAccountObj Mail account object
 * @param  {app handler}     mailHandler    Mail 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 a Mail mailbox and store this data
 * @param  {list of objects} projectObjs      List of all project objects
 * @param  {list of strings} mailRootBoxPaths List of all Mail root mailboxes
 * @param  {string}          mailRootBoxPath  Mail root mailboxes
 * @param  {object}          mailRootBoxObj   Mail 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 = makeId(name);

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

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

    setProjectDefaults(projectObjs, id, name);

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

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

/**
 * Parse a given list of Obsidian vaults
 * @param  {list of objects} projectObjs List of all project objects
 * @param  {list of objects} obVaultObjs List of Obsidian vault objects
 * @param  {app handler}     seHandler   System Event application handler
 * @return {bool}                        Function exit status
 */
const parseObVaults = (projectObjs, obVaultObjs, seHandler) => {
  for (const 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.name); // Debug

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

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

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

      $.exit(1);
    }

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

    obFolderObj.files = seHandler.folders.byName(obFolderObj.path).diskItems;

    getObFiles(projectObjs, obFolderObj);
  }
};

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

    if (! /\.md$/.test(obFileObj.name())) {
      continue;
    }

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

    if (/^(-|[0-9]+) +/.test(name)) {
      continue;
    }

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

    setProjectDefaults(projectObjs, id, name);

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

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

/**
 * Parse a given list of OmniFocus folders
 * @param  {list of objects} projectObjs   List of all project objects
 * @param  {list of strings} ofFolderNames List of OmniFocus folder paths
 * @param  {app handler}     ofHandler     OmniFocus application handler
 * @return {bool}                          Function exit status
 */
const parseOfFolders = (projectObjs, ofFolderNames, ofHandler) => {
  for (const ofFolderName of ofFolderNames) {
    const ofFolderObj = ofHandler.defaultDocument.folders.byName(ofFolderName);

    if (! ofFolderObj.exists()) {
      console.log('Error finding OmniFocus folder:', ofFolderName);

      $.exit(1);
    }

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

      continue;
    }

    // console.log('Debug parseOfFolders:', ofFolderName); // Debug

    getOfProjects(projectObjs, ofFolderObj);
  }
};

/**
 * Get the information of a OmniFocus project and store this data
 * @param  {list of objects} projectObjs List of all project objects
 * @param  {object}          ofFolderObj OmniFocus folder object
 * @return {bool}                        Function exit status
 */
const getOfProjects = (projectObjs, ofFolderObj) => {
  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 id = makeId(name);

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

    setProjectDefaults(projectObjs, id, name);

    projectObjs[id].ofProjectExists = true;
    projectObjs[id].ofProjectPath = [ofFolderObj.name(), name].join('/');
    projectObjs[id].ofProjectUrl = 'omnifocus:///task/' +
                                   ofProjectObj.rootTask.id();

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

// -----

/**
 * Parse a given list of OmniFocus projects
 * @param  {list of objects} projectObjs    List of all project objects
 * @param  {list of strings} ofProjectPaths List of OmniFocus project paths
 * @param  {app handler}     ofHandler      OmniFocus application handler
 * @return {bool}                           Function exit status
 */
const parseOfProjects = (projectObjs, ofProjectPaths, ofHandler) => {
  for (const ofProjectPath of ofProjectPaths) {
    const ofProjectObj = ofHandler.defaultDocument.projects
                                  .byName(ofProjectPath);

    if (! ofProjectObj.exists()) {
      console.log('Error finding OmniFocus project:', ofProjectPath);

      $.exit(1);
    }

    if (ofProjectObj.completed() || ofProjectObj.dropped()) {
      console.log('Warn: OmniFocus project is completed or dropped:',
                  ofProjectPath);

      continue;
    }

    // console.log('Debug parseOfProjects:', ofProject); // Debug

    getOfTasks(projectObjs, ofProjectObj);
  }
};

/**
 * Get the information of a OmniFocus task and store this data
 * @param  {list of objects} projectObjs  List of all project objects
 * @param  {object}          ofProjectObj OmniFocus project object
 * @return {bool}                         Function exit status
 */
const getOfTasks = (projectObjs, ofProjectObj) => {
  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 id = makeId(name);

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

    setProjectDefaults(projectObjs, id, name);

    projectObjs[id].ofTaskExists = true;
    projectObjs[id].ofTaskPath = [ofProjectObj.name(), name].join('/');
    projectObjs[id].ofTaskUrl = 'omnifocus:///task/' + ofTaskObj.id();

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

// -----

/**
 * Insert the project's File System and Obsidian links into the OmniFocus note
 * @param  {object} projectObj Project object
 * @param  {object} ofTaskObj  OmniFocus task object
 * @return {bool}              Function exit status
 */
const insertLinksIntoOfNote = (projectObj, ofTaskObj) => {
  if (projectObj.fsFolderExists && ! /file:\/\//.test(ofTaskObj.note())) {
    // console.log('Debug insertLinksIntoOfNote: Add File System link'); // Debug

    ofTaskObj.note = [ofTaskObj.note(), projectObj.fsFolderUrl].join('\n');
  }

  if (projectObj.obFileExists && ! /obsidian:\/\//.test(ofTaskObj.note())) {
    // console.log('Debug insertLinksIntoOfNote: Add Obsidian link'); // Debug

    ofTaskObj.note = [ofTaskObj.note(), projectObj.obFileUrl].join('\n');
  }
};

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

/**
 * Make an ID from a project name
 * @param  {string} name Project name
 * @return {string}      Project ID
 */
const makeId = (name) => {
  return name.trim().normalize('NFC').replace(/ /g, '_').toLowerCase();
};

/**
 * Create 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 all project objects
 * @param  {string}          id          Project ID
 * @param  {string}          name        Project name
 * @return {bool}                        Function exit status
 */
const setProjectDefaults = (projectObjs, id, name) => {
  if (! projectObjs.hasOwnProperty(id)) {
    projectObjs[id] = {
      name: name,
      ofProjectExists: false,
      ofTaskExists: false,
      obFileExists: false,
      mailBoxExists: false,
      fsFolderExists: false,
    };
  }
};

// -----------------------------------------------------------------------------
// 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 data
  const projectObjs = {};

  // Call the 'parseFsFolders' function
  // WARNING: This function must be called before the 'parseOfFolders' and
  //          'parseOfProjects' functions
  parseFsFolders(projectObjs, configJSON.filesystem, seHandler);

  // Call the 'parseMailAccounts' function
  parseMailAccounts(projectObjs, configJSON.mail, mailHandler);

  // Call the 'parseObVaults' function
  // WARNING: This function must be called before the 'parseOfFolders' and
  //          'parseOfProjects' functions
  parseObVaults(projectObjs, configJSON.obsidian, seHandler);

 // Call the 'parseOfFolders' function
  parseOfFolders(projectObjs, configJSON.omnifocus.folders, ofHandler);

  // Call the 'parseOfProjects' function
  parseOfProjects(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' +
                  '  - Link:   ', projectObj.ofProjectUrl);
    }
    else if (projectObj.ofTaskExists) {
      console.log('- OmniFocus task\n' +
                  '  - Path:   ', projectObj.ofTaskPath + '\n' +
                  '  - Link:   ', projectObj.ofTaskUrl);
    }
    else {
      console.log('‼️  Error no OmniFocus project found ‼️');
    }

    // Evaluate the File System related data
    if (projectObj.fsFolderExists) {
      console.log('- Filesystem folder\n' +
                  '  - Path:   ', projectObj.fsFolderPath + '\n' +
                  '  - Link:   ', 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' +
                  '  - Link:   ', projectObj.obFileUrl);
    }
  }

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

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

Usage

Create a configuration file
{
  "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"