#!/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);
})();
// -----------------------------------------------------------------------------
{
"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"
]
}
}