<?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("&", "&", $text); $text = str_replace("<", "<", $text); $text = str_replace(">", ">", $text); // 2: Convert Tabs to 4 's $text = str_replace("\t", " ", $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 into a tab (\t) $html = str_ireplace(" ", "\t", $html); // Replace other common HTML tags (like <div>, <span>, etc.) by stripping them $html = strip_tags($html); // Replace HTML entities like &, <, >, , 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); */