Newer
Older
TriliumScripts / trilium_sync.php
0xRoM on 21 Mar 25 KB initial commit
<?php

// Configuration
$apiToken = ""; // API Token (menu->options, ETAPI)
$serverUrl = "http://127.0.0.1:8181"; // location to your trilium instance
$parentNoteId = ""; // Change this to the parent note ID you want to list sub-notes for (The main folder/note)
$localFolder = '/NAS/Docs/trilium-data/'; // folder to sync to

// leave below here
$deletedFolders = array();
$deletedFiles = array();

function getTriliumVersion($apiToken, $serverUrl) {
    $url = "$serverUrl/etapi/app-info";
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: application/json"
    ]);
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode !== 200) {
        die("Error fetching notes: HTTP $httpCode\n$response");
    }
    
    return json_decode($response, true);
}

function checkFolderAccess($folderPath) {
    if (!is_dir($folderPath) || !is_readable($folderPath)) {
        die("Error: Cannot access folder '$folderPath'.");
    }
}

function fetchSubNotes($parentNoteId, $apiToken, $serverUrl) {
    $url = "$serverUrl/etapi/notes/$parentNoteId";
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: application/json"
    ]);
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode !== 200) {
        die("Error fetching notes: HTTP $httpCode\n$response");
    }
    
    return json_decode($response, true);
}

function fetchNoteContents($noteId, $apiToken, $serverUrl) {
    $url = "$serverUrl/etapi/notes/$noteId/content";
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: application/json"
    ]);
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode !== 200) {
        return "Error fetching notes: HTTP $httpCode\n$response";
    }

    $response = convertHtmlToText($response);
 
    return $response;
}

function updateNoteContents($noteId, $content, $apiToken, $serverUrl) {
    $url = "$serverUrl/etapi/notes/$noteId/content";

    $html = convertTextToHtml($content);

    // Initialise cURL request
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");  // Set the request method to PUT
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: text/plain"
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $html);  // Add formatted HTML to the body
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode !== 204) {
        die("Error updating note: HTTP $httpCode\n$response");
    }

    return;
}

function createNote($parentNoteId, $title, $content, $apiToken, $serverUrl) {
    $url = "$serverUrl/etapi/create-note";
    
    $data = json_encode([
        "parentNoteId" => $parentNoteId,
        "title" => $title,
        "type" => "text",
        "content" => $content
    ]);
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: application/json"
    ]);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $response = json_decode($response, true);
    return $response['note'];
}

function deleteNote($noteId, $apiToken, $serverUrl){
    $url = "$serverUrl/etapi/notes/$noteId";

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: $apiToken",
        "Content-Type: application/json"
    ]);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode === 204) {
        return true; // Deletion successful
    }
    
    return json_decode($response, true); // Return JSON response for errors
}

function fetchAllNotes($noteId, $apiToken, $serverUrl, $localFolder, $path = '') {
    $note = fetchSubNotes($noteId, $apiToken, $serverUrl);
    
    if (!empty($note['childNoteIds'])) {
        $currentPath = $path . $note['title'] . '/';

        $fullPath = rtrim($localFolder, '/') . '/' . ltrim($path, '/') . '/' . ltrim($note['title'], '/');
        $fullPath = preg_replace('/\/+/', '/', $fullPath);

        if (is_dir($fullPath)) {
            $status = 'Exists';
            $modifiedDisk = stat($fullPath)['mtime'];
        } else {
            $status = 'Missing_Disk';
            $modifiedDisk = "";
        }
        
        $folder = [
            'noteId' => $noteId,
            'folder' => $note['title'],
            'dateModifiedTrilium' => convertToUnixTimestamp($note['dateModified']),
            'dateModifiedDisk' => $modifiedDisk,
            'parent' => $note['parentNoteIds'][0],
            'children' => [],
            'status' => $status
        ];
        
        foreach ($note['childNoteIds'] as $childId) {
            $folder['children'][] = fetchAllNotes($childId, $apiToken, $serverUrl, $localFolder, $currentPath);
        }
        
        return $folder;
    } else {

        $fullPath = $localFolder . $path . $note['title'];
        //echo "does it exist?: $fullPath\n";
        $fileExists = file_exists($fullPath) ? 'Exists' : 'Missing_Disk';

        if ($fileExists === 'Exists' && is_file($fullPath)) {
            $modifiedDisk = convertToUnixTimestamp(filemtime($fullPath));
            $hashDisk = md5(file_get_contents($fullPath));
        } else {
            $modifiedDisk = "";
            $hashDisk = "";
        }

        $hashTrilium = md5(fetchNoteContents($noteId, $apiToken, $serverUrl));
        
        return [
            'noteId' => $noteId,
            'file' => $note['title'],
            'path' => $fullPath,
            'dateModifiedTrilium' => convertToUnixTimestamp($note['dateModified']),
            'dateModifiedDisk' => $modifiedDisk,
            'parent' => $note['parentNoteIds'][0],
            'hashTrilium' => $hashTrilium,
            'hashDisk' => $hashDisk,
            'hashMach' => "",
            'status' => $fileExists
        ];
    }
}

function scanLocalFiles($directory, $basePath, $path = '') {
    $files = [];
    $items = scandir($directory);

    foreach ($items as $item) {
        if ($item === '.' || $item === '..') continue;

        $fullPath = rtrim($directory, '/') . '/' . $item;
        $relativePath = str_replace($basePath, '', $fullPath);
        $currentPath = rtrim($path, '/') . '/' . $item;

        if (is_dir($fullPath)) {
            $modifiedDisk = stat($fullPath)['mtime'];
            $files[] = [
                'noteId' => "",
                'folder' => $item,
                'dateModifiedTrilium' => "",
                'dateModifiedDisk' => $modifiedDisk,
                'children' => scanLocalFiles($fullPath, $basePath, $currentPath), // Recursive call for directories
                'parent' => "",
                'status' => 'Missing_Trilium',
            ];
        } else {
            $hashDisk = md5(file_get_contents($fullPath));
            $files[] = [
                'noteId' => "",
                'file' => $item,
                'dateModifiedTrilium' => "",
                'dateModifiedDisk' => convertToUnixTimestamp(filemtime($fullPath)),
                'status' => 'Missing_Trilium',
                'parent' => "",
                'hashTrilium' => '',
                'hashDisk' => $hashDisk,
                'hashMach' => "",
                'path' => ltrim($relativePath, '/'),
            ];
        }
    }

    return $files;
}

function mergeStructures(&$triliumStructure, $localFiles) {
    $triliumPaths = [];
    
    if (isset($triliumStructure['children'])) {
        foreach ($triliumStructure['children'] as &$child) {
            $key = isset($child['folder']) ? $child['folder'] : $child['file'];
            $triliumPaths[$key] = &$child;
        }
    } else {
        $triliumStructure['children'] = [];
    }
    
    foreach ($localFiles as $localItem) {
        $key = isset($localItem['folder']) ? $localItem['folder'] : $localItem['file'];
        
        if (!isset($triliumPaths[$key])) {
            if (isset($localItem['path'])) {
                $localItem['path'] = preg_replace('|^[^/]+/|', '', $localItem['path']);
            }
            $triliumStructure['children'][] = $localItem;
        } else if (isset($localItem['children'])) {
            mergeStructures($triliumPaths[$key], $localItem['children']);
        }
    }
}

function compareFileHashes(&$item) {
    // If the item is an array, check if it has children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            compareFileHashes($child);
        }
    }

    // Perform hash comparison only if it's a leaf node (has 'file' but no 'children')
    if (isset($item['file']) && (!isset($item['children']) || empty($item['children']))) {
        if (!empty($item['hashTrilium']) && !empty($item['hashDisk'])) {
            if ($item['hashTrilium'] === $item['hashDisk']) {
                $item['hashMatch'] = "Match";
            } else { 
                $item['hashMatch'] = 'Diff'; 
                if($item['dateModifiedTrilium'] < $item['dateModifiedDisk']){ $item['status'] ="Update_Trilium"; }
                if($item['dateModifiedTrilium'] > $item['dateModifiedDisk']){ $item['status'] ="Update_Disk"; }
            }
        } else {
            $item['hashMatch'] = 'N/A';
        }

    }
}

function createTriliumFolder(&$item, $path = '',$parentNoteId = null) {
    global $localFolder, $apiToken, $serverUrl;
    global $deletedFolders, $deletedFiles; 
    // Assign the parent's noteId as the parentId for the current item

    if (isset($item['folder']) && strpos($item['folder'], '[del]') !== 0  ) {

        $startsWithDeleted = false;
        foreach ($deletedFolders as $deletedFolder) {
            if (strncmp($path . $item['folder'] ."/", $deletedFolder, strlen($deletedFolder)) === 0) {
                $startsWithDeleted = true;
                break;
            }
        }

        if (!empty($item['status']) && $item['status'] == "Missing_Trilium" ) {
            if(!$startsWithDeleted){
                $fullpath =  $path . $item['folder'] . '/';
                echo "[+] creating trilium folder: $fullpath\n";
                
                $newId = createNote($parentNoteId, $item['folder'], "", $apiToken, $serverUrl);
                // Update item with new noteId
                $item['noteId'] = $newId['noteId'];
                $item['dateModifiedTrilium'] = time();
                $item['status'] = "Updated";
            } else{
                $item['status'] = "Skipped";
            }
        }
    }
    
    // Update parentId for immediate children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            if (isset($item['noteId']) && !empty($item['noteId'])) {
                $child['parent'] = $item['noteId'];
            }
            $currentPath = $path . $item['folder'] . '/';
            createTriliumFolder($child, $currentPath, $item['noteId']);
        }
    }
}

function createTriliumFile(&$item, $path = '',$parentNoteId = null) {
    global $localFolder, $apiToken, $serverUrl;
    global $deletedFolders, $deletedFiles; 

    if (isset($item['file']) && strpos($item['file'], '[del]') !== 0) {

        $startsWithDeleted = false;
        foreach ($deletedFolders as $deletedFolder) {
            if (strncmp($path , $deletedFolder, strlen($deletedFolder)) === 0) {
                $startsWithDeleted = true;
                break;
            }
        }
        foreach ($deletedFiles as $deletedFile) {
            if (strncmp($path . $item['file'] , $deletedFile, strlen($deletedFile)) === 0) {
                $startsWithDeleted = true;
                break;
            }
        }

        if (!empty($item['status']) && $item['status'] == "Missing_Trilium") {
            if(!$startsWithDeleted){
                $fullpath =  $localFolder . $path . $item['file'] ;
                echo "[+] creating trilium note: $fullpath\n";
                
                $contents = file_get_contents($fullpath);
                $newId = createNote($parentNoteId, $item['file'], $contents, $apiToken, $serverUrl);
                // Update item with new noteId
                $item['noteId'] = $newId['noteId'];
                $item['dateModifiedTrilium'] = time();
                $item['status'] = "Updated";
            }else{ $item['status'] = "Skipped"; }
        }
    }
    
    // Update parentId for immediate children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            if (isset($item['noteId']) && !empty($item['noteId'])) {
                $child['parent'] = $item['noteId'];
            }
            $currentPath = $path . $item['folder'] . '/';
            createTriliumFile($child, $currentPath, $item['noteId']);
        }
    }
}

function createDiskFileAndFolder(&$item, $path = '', $parentNoteId = null) {
    global $localFolder, $apiToken, $serverUrl;
    global $deletedFolders, $deletedFiles; 

    // Assign the parent's noteId as the parentId for the current item
    if (isset($item['folder']) && strpos($item['folder'], '[del]') !== 0) {
        if (!empty($item['status']) && $item['status'] == "Missing_Disk") {
            $fullpath = $localFolder . $path . $item['folder'] . '/';
            echo "[+] creating disk folder: $fullpath\n";
            
            mkdir($fullpath, 0777, true);
            $item['dateModifiedDisk'] = time();
            $item['status'] = "Updated";
        }
    }

    if (isset($item['file']) && strpos($item['file'], '[del]') !== 0) {

        $startsWithDeleted = false;
        foreach ($deletedFolders as $deletedFolder) {
            if (strncmp($path , $deletedFolder, strlen($deletedFolder)) === 0) {
                $startsWithDeleted = true;
                break;
            }
        }
        foreach ($deletedFiles as $deletedFile) {
            if (strncmp($path . $item['file'] , $deletedFile, strlen($deletedFile)) === 0) {
                $startsWithDeleted = true;
                break;
            }
        }

        if (!empty($item['status']) && $item['status'] == "Missing_Disk") {
            if(!$startsWithDeleted){
                $fullpath =  $localFolder . $path . $item['file'] ;
                echo "[+] creating disk note: $fullpath\n";
                
                file_put_contents($fullpath, fetchNoteContents($item['noteId'], $apiToken, $serverUrl) );
                $item['dateModifiedDisk'] = time();
                $item['status'] = "Updated";
            }else{ $item['status'] = "Skipped"; }
        }
    }
  
    // Update parentId for immediate children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            if (isset($item['noteId']) && !empty($item['noteId'])) {
                $child['parent'] = $item['noteId'];
            }
            $currentPath = $path . $item['folder'] . '/';
            createDiskFileAndFolder($child, $currentPath, $item['noteId']);
        }
    }
}

function deleteItem(&$item, $path = '', $parentNoteId = null) {
    global $localFolder, $apiToken, $serverUrl;
    global $deletedFolders, $deletedFiles; 
    // Assign the parent's noteId as the parentId for the current item
    if (isset($item['folder']) && strpos($item['folder'], '[del]') === 0) {
        $item['status'] = "Delete";
        
        $item_fixed = trim(substr($item['folder'], 5)); // Remove "[del]" from the start
        $fullpath = $localFolder . $path .  $item_fixed . '/';
        echo "[-] deleting disk folder: $fullpath\n";
        $deletedFolders[] = $path .  $item_fixed . '/';
        $deletedFolders[] = $path .  $item['folder'] . '/';

        deleteFolderAndContents($fullpath);
        deleteNote($item['noteId'], $apiToken, $serverUrl);
    }
    
    if (isset($item['file']) && strpos($item['file'], '[del]') === 0) {
        $item['status'] = "Delete";

        $item_fixed = trim(substr($item['file'], 5)); // Remove "[del]" from the start
        $fullpath = $localFolder . $path .  $item_fixed ;
        echo "[-] deleting disk file: $fullpath\n";
        $deletedFiles[] = $path .  $item_fixed;
        $deletedFiles[] = $path .  $item['file'];

        deleteNote($item['noteId'], $apiToken, $serverUrl);
        if (file_exists($fullpath)) {
            if (is_file($fullpath)) {
                unlink($fullpath);
            } elseif (is_dir($fullpath)) {
                foreach (array_diff(scandir($fullpath), ['.', '..']) as $file) {
                    unlink("$fullpath") ?: rmdir("$fullpath");
                }
                rmdir($fullpath);
            }
        }

    }
    
    // Update parentId for immediate children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            if (isset($item['folder']) && strpos($item['folder'], '[del]') === 0)
                $child['status'] == "Delete";
            $currentPath = $path . $item['folder'] . '/';
            deleteItem($child, $currentPath, $item['noteId']);
        }
    }
}

function deleteFolderAndContents($folderPath) {
    if (!is_dir($folderPath)) {
        return false; // Ensure it's a directory
    }

    // Scan the directory for all files and subdirectories
    $files = array_diff(scandir($folderPath), array('.', '..'));

    foreach ($files as $file) {
        $filePath = $folderPath . DIRECTORY_SEPARATOR . $file;

        if (is_dir($filePath)) {
            deleteFolderAndContents($filePath);
        } else {

            unlink($filePath);
        }
    }
    return rmdir($folderPath);
}

function updateExistingFile(&$item, $path = '', $parentNoteId = null){
    global $localFolder, $apiToken, $serverUrl;

    if (isset($item['file'])) {
        if (!empty($item['status']) && $item['status'] == "Update_Trilium") {
            $fullpath =  $localFolder . $path . $item['file'] ;
            echo "[+] updating file on trilium: $fullpath\n";
            
            $content = file_get_contents($fullpath);
            updateNoteContents($item['noteId'], $content, $apiToken, $serverUrl);
            $item['dateModifiedTrilium'] = time();
            $item['status'] = "Updated";
        }
    }

    if (isset($item['file'])) {
        if (!empty($item['status']) && $item['status'] == "Update_Disk") {
            $fullpath =  $localFolder . $path . $item['file'] ;
            echo "[+] updating file on disk: $fullpath\n";
            
            file_put_contents($fullpath, fetchNoteContents($item['noteId'], $apiToken, $serverUrl) );
            $item['dateModifiedDisk'] = time();
            $item['status'] = "Updated";
        }
    }
    
    // Update parentId for immediate children
    if (isset($item['children']) && is_array($item['children'])) {
        foreach ($item['children'] as &$child) {
            $currentPath = $path . $item['folder'] . '/';
            updateExistingFile($child, $currentPath, $item['noteId']);
        }
    }
}

function printFolderTable($structure, $path = '', $printHeader = true) {
    // Define column widths to ensure consistent formatting
    $columnWidths = [
        'path' => 25,
        'folder' => 25,
        'parent' => 13,
        'noteId' => 13,
        'dateModifiedTrilium' => 11,
        'dateModifiedDisk' => 11,
        'status' => 15
    ];

    if ($printHeader) {
        echo "- Folders -\n";
        echo formatColumn("Folder/Path", 25) . 
             formatColumn("Folder Name", 25) . 
             formatColumn("Parent", 13) . 
             formatColumn("noteId", 13) . 
             formatColumn("Mod Trl", 11) . 
             formatColumn("Mod Dsk", 11) . 
             formatColumn("Status", 15) . "\n";
    }

    if (isset($structure['folder'])) {
        $currentPath = $path . $structure['folder'] . '/';
        
        // Print the folder details
        echo formatColumn($currentPath, $columnWidths['path']) . 
             formatColumn($structure['folder'], $columnWidths['folder']) .
             formatColumn($structure['parent'], $columnWidths['parent']) . 
             formatColumn($structure['noteId'], $columnWidths['noteId']) .
             formatColumn($structure['dateModifiedTrilium'], $columnWidths['dateModifiedTrilium']) . 
             formatColumn($structure['dateModifiedDisk'], $columnWidths['dateModifiedDisk']) .
             formatColumn($structure['status'], $columnWidths['status']) . "\n"; 
        
        // Recursively process children
        foreach ($structure['children'] as $child) {
            printFolderTable($child, $currentPath, false);
        }
    }
}

function printFileTable($structure, $path = '', $printHeader = true) {
    // Define column widths to ensure consistent formatting
    $columnWidths = [
        'path' => 25,
        'parent' => 13,
        'noteId' => 13,
        'file' => 25,
        'dateModifiedTrilium' => 11,
        'dateModifiedDisk' => 11,
        'hashMatch' => 6,
        'status' => 15
    ];

    if ($printHeader) {
        echo "- Files -\n";
        echo formatColumn("Folder/Path", 25) . 
             formatColumn("Filename", 25) . 
             formatColumn("Parent", 13) .
             formatColumn("noteId", 13) . 
             formatColumn("Mod Trl", 11) . 
             formatColumn("Mod Dsk", 11) . 
             formatColumn("Hash", 6) . 
             formatColumn("Status", 15) . "\n";
    }

    if (isset($structure['folder'])) {
        $currentPath = $path . $structure['folder'] . '/';
        foreach ($structure['children'] as $child) {
            printFileTable($child, $currentPath, false);
        }
    } else {
        // Format and print the table row with aligned columns
        echo formatColumn($path, $columnWidths['path']) . 
             formatColumn($structure['file'], $columnWidths['file']) . 
             formatColumn($structure['parent'], $columnWidths['parent']) . 
             formatColumn($structure['noteId'], $columnWidths['noteId']) . 
             formatColumn($structure['dateModifiedTrilium'], $columnWidths['dateModifiedTrilium']) . 
             formatColumn($structure['dateModifiedDisk'], $columnWidths['dateModifiedDisk']) . 
             formatColumn($structure['hashMatch'], $columnWidths['hashMatch']) .
             formatColumn($structure['status'], $columnWidths['status']) . "\n";
    }
}

function convertToUnixTimestamp($timestamp) {
    // If timestamp is already an integer (Unix timestamp), return it directly
    if (is_int($timestamp)) {
        return $timestamp;
    }
    
    // For other formats, convert to Unix timestamp
    $date = new DateTime($timestamp);
    return $date->getTimestamp();
}

function convertTextToHtml($text) {
    // 1: Plain Text Search
    $text = str_replace("&", "&amp;", $text);
    $text = str_replace("<", "&lt;", $text);
    $text = str_replace(">", "&gt;", $text);

    // 2: Convert Tabs to 4 &nbsp;'s
    $text = str_replace("\t", "&nbsp;&nbsp;&nbsp;&nbsp;", $text);

    // 3: Line Breaks
    $text = preg_replace("/\r\n?|\n/", "<br>", $text);

    // 4: Paragraphs (Replacing two or more line breaks with paragraph tags)
    $text = preg_replace("/<br>\s*<br>/", "</p><p>", $text);

    // 5: Wrap in Paragraph Tags
    $text = "<p>" . $text . "</p>";

    return $text;
}

function convertHtmlToText($html) {
    // Replace <br>, <br/> and <br /> tags with newlines
    $html = str_ireplace(["<br>", "<br/>", "<br />"], "\n", $html);

    // Replace <p> and </p> tags with double newlines (to separate paragraphs)
    $html = str_ireplace("</p>", "\n\n", $html);
    $html = str_ireplace("<p>", "", $html); // Remove opening <p> tags

    // Convert four &nbsp; into a tab (\t)
    $html = str_ireplace("&nbsp;&nbsp;&nbsp;&nbsp;", "\t", $html);

    // Replace other common HTML tags (like <div>, <span>, etc.) by stripping them
    $html = strip_tags($html);

    // Replace HTML entities like &amp;, &lt;, &gt;, &nbsp;, etc. with their corresponding characters
    $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');

    // Remove excessive spaces and normalise newlines
    $html = preg_replace('/[ ]+/', ' ', $html);  // Replace multiple spaces or tabs with a single space
    $html = trim($html);  // Remove leading/trailing whitespace

    return $html;
}

function formatColumn($str, $width) {
    // Function to pad the strings to align the columns
    return str_pad($str, $width, ' ', STR_PAD_RIGHT);
}

echo "[i] checking connections\n";
$appInfo = getTriliumVersion($apiToken, $serverUrl);
checkFolderAccess($localFolder);
echo "[i] version: ".$appInfo['appVersion']."\n";

echo "[i] gathering info\n";
$structure = fetchAllNotes($parentNoteId, $apiToken, $serverUrl, $localFolder);
$localFiles = scanLocalFiles($localFolder."Synced/", $localFolder."Synced/");
mergeStructures($structure, $localFiles);
compareFileHashes($structure);

// Print the data tables
printFolderTable($structure);
printFileTable($structure);

// syncing operations
deleteItem($structure);
createTriliumFolder($structure);
createTriliumFile($structure);
createDiskFileAndFolder($structure);
updateExistingFile($structure);

/*
echo "####### DEBUG ##########\n";
printFolderTable($structure);
printFileTable($structure);
*/