<?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);
*/