#!/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);
})();
// -----------------------------------------------------------------------------
{
"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"
]
}
}
chmod 0700 "${HOME}/dev/align-projects.jxa"
"${HOME}/dev/align-projects.jxa"