// ***************************************************************************** // * This file is part of the FreeFileSync project. It is distributed under * // * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * // * Copyright (C) Zenju (zenju AT freefilesync DOT org) - All Rights Reserved * // ***************************************************************************** #include "log_file.h" #include #include using namespace zen; using namespace fff; using AFS = AbstractFileSystem; namespace { const int LOG_PREVIEW_MAX = 25; const int EMAIL_PREVIEW_MAX = LOG_PREVIEW_MAX; const int EMAIL_ITEMS_MAX = 250; const int EMAIL_SHORT_PREVIEW_MAX = 5; //summary email const int EMAIL_SHORT_ITEMS_MAX = 0; // const int SEPARATION_LINE_LEN = 40; std::string generateLogHeaderTxt(const ProcessSummary& s, const ErrorLog& log, int logPreviewMax) { const auto tabSpace = utfTo(TAB_SPACE); std::string headerLine; for (const std::wstring& jobName : s.jobNames) headerLine += (headerLine.empty() ? "" : " + ") + utfTo(jobName); if (!headerLine.empty()) headerLine += ' '; const TimeComp tc = getLocalTime(std::chrono::system_clock::to_time_t(s.startTime)); //returns TimeComp() on error headerLine += utfTo(formatTime(formatDateTag, tc) + Zstr(" [") + formatTime(formatTimeTag, tc) + Zstr(']')); //assemble summary box std::vector summary; summary.emplace_back(); summary.push_back(tabSpace + utfTo(getSyncResultLabel(s.result))); summary.emplace_back(); const ErrorLogStats logCount = getStats(log); if (logCount.errors > 0) summary.push_back(tabSpace + utfTo(_("Errors:") + L' ' + formatNumber(logCount.errors))); if (logCount.warnings > 0) summary.push_back(tabSpace + utfTo(_("Warnings:") + L' ' + formatNumber(logCount.warnings))); summary.push_back(tabSpace + utfTo(_("Items processed:") + L' ' + formatNumber(s.statsProcessed.items) + //show always, even if 0! L" (" + formatFilesizeShort(s.statsProcessed.bytes) + L')')); if ((s.statsTotal.items < 0 && s.statsTotal.bytes < 0) || //no total items/bytes: e.g. cancel during folder comparison s.statsProcessed == s.statsTotal) //...if everything was processed successfully ; else summary.push_back(tabSpace + utfTo(_("Items remaining:") + L' ' + formatNumber (s.statsTotal.items - s.statsProcessed.items) + L" (" + formatFilesizeShort(s.statsTotal.bytes - s.statsProcessed.bytes) + L')')); const int64_t totalTimeSec = std::chrono::duration_cast(s.totalTime).count(); summary.push_back(tabSpace + utfTo(_("Total time:")) + ' ' + utfTo(formatTimeSpan(totalTimeSec))); size_t sepLineLen = 0; //calculate max width (considering Unicode!) for (const std::string& str : summary) sepLineLen = std::max(sepLineLen, unicodeLength(str)); std::string output = headerLine + '\n'; output += std::string(sepLineLen + 1, '_') + '\n'; for (const std::string& str : summary) output += '|' + str + '\n'; output += '|' + std::string(sepLineLen, '_') + "\n\n"; //------------ warnings/errors preview ---------------- const int logFailTotal = logCount.warnings + logCount.errors; if (logFailTotal > 0) { output += '\n' + utfTo(_("Errors and warnings:")) + '\n'; output += std::string(SEPARATION_LINE_LEN, '_') + '\n'; int previewCount = 0; for (const LogEntry& entry : log) if (entry.type & (MSG_TYPE_WARNING | MSG_TYPE_ERROR)) { if (previewCount++ >= logPreviewMax) break; output += utfTo(formatMessage(entry)); } if (logFailTotal > previewCount) output += " [...] " + utfTo(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logFailTotal), //%x used as plural form placeholder! L"%y", formatNumber(previewCount))) + '\n'; output += std::string(SEPARATION_LINE_LEN, '_') + "\n\n\n"; } return output; } std::string generateLogFooterTxt(const std::wstring& logFilePath /*optional*/, int logItemsTotal, int logItemsMax) //throw FileError { const ComputerModel cm = getComputerModel(); //throw FileError std::string output; if (logItemsTotal > logItemsMax) output += " [...] " + utfTo(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logItemsTotal), //%x used as plural form placeholder! L"%y", formatNumber(logItemsMax))) + '\n'; output += std::string(SEPARATION_LINE_LEN, '_') + '\n' + utfTo(getOsDescription() + /*throw FileError*/ + L" - " + utfTo(getUserDescription()) /*throw FileError*/ + (!cm.model .empty() ? L" - " + cm.model : L"") + (!cm.vendor.empty() ? L" - " + cm.vendor : L"")) + '\n'; if (!logFilePath.empty()) output += utfTo(_("Log file:") + L' ' + logFilePath) + '\n'; return output; } std::string htmlTxt(const std::string_view& str) { std::string msg = htmlSpecialChars(str); trim(msg); if (!contains(msg, '\n')) return msg; std::string msgFmt; for (auto it = msg.begin(); it != msg.end(); ) if (*it == '\n') { msgFmt += "
\n"; ++it; //skip duplicate newlines for (; it != msg.end() && *it == L'\n'; ++it) ; //preserve leading spaces for (; it != msg.end() && *it == L' '; ++it) msgFmt += " "; } else msgFmt += *it++; return msgFmt; } std::string htmlTxt(const Zstring& str) { return htmlTxt(utfTo(str)); } std::string htmlTxt(const std::wstring& str) { return htmlTxt(utfTo(str)); } std::string htmlTxt(const wchar_t* str) { return htmlTxt(utfTo(str)); } //Astyle screws up royally with the following raw string literals! //*INDENT-OFF* std::string formatMessageHtml(const LogEntry& entry) { const std::string typeLabel = htmlTxt(getMessageTypeLabel(entry.type)); const char* typeImage = nullptr; switch (entry.type) { case MSG_TYPE_INFO: typeImage = "msg-info.png"; break; case MSG_TYPE_WARNING: typeImage = "msg-warning.png"; break; case MSG_TYPE_ERROR: typeImage = "msg-error.png"; break; } //*both* width + height are required (or at least Thunderbird image size calculation glitches out) return R"( )" + htmlTxt(formatTime(formatTimeTag, getLocalTime(entry.time))) + R"( ) )" + htmlTxt(makeStringView(entry.message.begin(), entry.message.end())) + R"( )"; } std::wstring generateLogTitle(const ProcessSummary& s) { std::wstring jobNamesFmt; for (const std::wstring& jobName : s.jobNames) jobNamesFmt += (jobNamesFmt.empty() ? L"" : L" + ") + jobName; std::wstring title = L"[FreeFileSync] "; if (!jobNamesFmt.empty()) title += jobNamesFmt + L' '; switch (s.result) { case TaskResult::success: title += utfTo("\xe2\x9c\x94" "\xef\xb8\x8f"); break; //✔️ case TaskResult::warning: title += utfTo("\xe2\x9a\xa0" "\xef\xb8\x8f"); break; //⚠️ case TaskResult::error: //efb88f (U+FE0F): variation selector-16 to prefer emoji over text rendering case TaskResult::cancelled: title += utfTo("\xe2\x9d\x8c" "\xef\xb8\x8f"); break; //❌️ } return title; } std::string generateLogHeaderHtml(const ProcessSummary& s, const ErrorLog& log, int logPreviewMax) { //caveat: non-inline CSS is often ignored by email clients! std::string output = R"( )" + htmlTxt(generateLogTitle(s)) + R"( )"; std::string jobNamesFmt; for (const std::wstring& jobName : s.jobNames) jobNamesFmt += (jobNamesFmt.empty() ? "" : " + ") + htmlTxt(jobName); const TimeComp tc = getLocalTime(std::chrono::system_clock::to_time_t(s.startTime)); //returns TimeComp() on error output += R"(
)" + jobNamesFmt + R"(  )" + htmlTxt(formatTime(formatDateTag, tc)) + "  " + htmlTxt(formatTime(formatTimeTag, tc)) + "
\n"; std::string resultsStatusImage; switch (s.result) { case TaskResult::success: resultsStatusImage = "result-succes.png"; break; case TaskResult::warning: resultsStatusImage = "result-warning.png"; break; case TaskResult::error: case TaskResult::cancelled: resultsStatusImage = "result-error.png"; break; } output += R"(
)" + htmlTxt(getSyncResultLabel(s.result)) + R"(
)"; const ErrorLogStats logCount = getStats(log); if (logCount.errors > 0) output += R"( )"; if (logCount.warnings > 0) output += R"( )"; output += R"( )"; if ((s.statsTotal.items < 0 && s.statsTotal.bytes < 0) || //no total items/bytes: e.g. for pure folder comparison s.statsProcessed == s.statsTotal) //...if everything was processed successfully ; else output += R"( )"; const int64_t totalTimeSec = std::chrono::duration_cast(s.totalTime).count(); output += R"(
)"; //------------ warnings/errors preview ---------------- const int logFailTotal = logCount.warnings + logCount.errors; if (logFailTotal > 0) { output += R"(
)" + htmlTxt(_("Errors and warnings:")) + R"(
)"; int previewCount = 0; for (const LogEntry& entry : log) if (entry.type & (MSG_TYPE_WARNING | MSG_TYPE_ERROR)) { if (previewCount++ >= logPreviewMax) break; output += formatMessageHtml(entry); } output += R"(
)"; if (logFailTotal > previewCount) output += R"(
[…])" + htmlTxt(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logFailTotal), //%x used as plural form placeholder! L"%y", formatNumber(previewCount))) + "
\n"; output += R"(

)"; } output += R"( )"; return output; } std::string generateLogFooterHtml(const std::wstring& logFilePath /*optional*/, int logItemsTotal, int logItemsMax) //throw FileError { const std::string osImage = "os-linux.png"; const ComputerModel cm = getComputerModel(); //throw FileError std::string output = R"(
)"; if (logItemsTotal > logItemsMax) output += R"(
[…])" + htmlTxt(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logItemsTotal), //%x used as plural form placeholder! L"%y", formatNumber(logItemsMax))) + "
\n"; output += R"(
)" + htmlTxt(getOsDescription()) + /*throw FileError*/ + " – " + htmlTxt(getUserDescription()) /*throw FileError*/ + (!cm.model .empty() ? " – " + htmlTxt(cm.model ) : "") + (!cm.vendor.empty() ? " – " + htmlTxt(cm.vendor) : "") + R"(
)"; if (!logFilePath.empty()) output += R"(
) )" + htmlTxt(logFilePath) + R"(
)"; output += R"( )"; return output; } //*INDENT-ON* //write log items in blocks instead of creating one big string: memory allocation might fail; think 1 million entries! template void streamToLogFile(const ProcessSummary& summary, const ErrorLog& log, int logPreviewMax, int logItemsMax, const std::wstring& logFilePath /*optional*/, LogFileFormat logFormat, Function stringOut /*(const std::string& s); throw X*/) //throw SysError, X { stringOut(logFormat == LogFileFormat::html ? generateLogHeaderHtml(summary, log, logPreviewMax) : generateLogHeaderTxt (summary, log, logPreviewMax)); //throw X int itemCount = 0; for (const LogEntry& entry : log) { if (itemCount++ >= logItemsMax) break; stringOut(logFormat == LogFileFormat::html ? formatMessageHtml(entry) : formatMessage (entry)); //throw X } const std::string footer = [&] { try { return logFormat == LogFileFormat::html ? generateLogFooterHtml(logFilePath, static_cast(log.size()), logItemsMax): //throw FileError generateLogFooterTxt (logFilePath, static_cast(log.size()), logItemsMax); // } catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //errors should be further enriched by context info => SysError }(); //caveat: don't catch exceptions thrown by stringOut()! stringOut(footer); //throw X } void saveNewLogFile(const AbstractPath& logFilePath, //throw FileError, X LogFileFormat logFormat, const ProcessSummary& summary, const ErrorLog& log, const std::function& notifyStatus /*throw X*/) { //create logfile folder if required if (const std::optional parentPath = AFS::getParentPath(logFilePath)) try { AFS::createFolderIfMissingRecursion(*parentPath); //throw FileError } catch (const FileError& e) //add context info regarding log file! { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(AFS::getDisplayPath(logFilePath))), e.toString()); } //----------------------------------------------------------------------- auto notifyUnbufferedIO = [notifyStatus, bytesWritten_ = int64_t(0), msg_ = replaceCpy(_("Saving file %x..."), L"%x", fmtPath(AFS::getDisplayPath(logFilePath)))] (int64_t bytesDelta) mutable { if (notifyStatus) notifyStatus(msg_ + L" (" + formatFilesizeShort(bytesWritten_ += bytesDelta) + L')'); //throw X }; //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) std::unique_ptr logFileOut = AFS::getOutputStream(logFilePath, std::nullopt /*streamSize*/, std::nullopt /*modTime*/); //throw FileError BufferedOutputStream streamOut([&](const void* buffer, size_t bytesToWrite) { return logFileOut->tryWrite(buffer, bytesToWrite, notifyUnbufferedIO); //throw FileError, X }, logFileOut->getBlockSize()); try { streamToLogFile(summary, log, LOG_PREVIEW_MAX, std::numeric_limits::max() /*logItemsMax*/, std::wstring() /*logFilePath -> superfluous*/, logFormat, [&](const std::string& str) { streamOut.write(str.data(), str.size()); } /*throw FileError, X*/); //throw SysError, FileError, X } catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(AFS::getDisplayPath(logFilePath))), e.toString()); } streamOut.flushBuffer(); //throw FileError, X logFileOut->finalize(notifyUnbufferedIO); //throw FileError, X } const int TIME_STAMP_LENGTH = 21; const Zchar STATUS_BEGIN_TOKEN[] = Zstr(" ["); const Zchar STATUS_END_TOKEN = Zstr(']'); struct LogFileInfo { AbstractPath filePath; time_t timeStamp; std::wstring jobNames; //may be empty }; std::vector getLogFiles(const AbstractPath& logFolderPath) //throw FileError { std::vector logfiles; AFS::traverseFolder(logFolderPath, [&](const AFS::FileInfo& fi) //throw FileError { //"Backup FreeFileSync 2013-09-15 015052.123.html" //"Jobname1 + Jobname2 2013-09-15 015052.123.log" //"2013-09-15 015052.123 [Error].log" static_assert(TIME_STAMP_LENGTH == 21); if (endsWith(fi.itemName, Zstr(".log")) || //case-sensitive: e.g. ".LOG" is not from FFS, right? endsWith(fi.itemName, Zstr(".html"))) { ZstringView itemPhrase = beforeLast(fi.itemName, Zstr('.'), IfNotFoundReturn::none); if (endsWith(itemPhrase, STATUS_END_TOKEN)) itemPhrase = beforeLast(itemPhrase, STATUS_BEGIN_TOKEN, IfNotFoundReturn::all); if (itemPhrase.size() >= TIME_STAMP_LENGTH && itemPhrase.end()[-4] == Zstr('.') && isdigit(itemPhrase.end()[-3]) && isdigit(itemPhrase.end()[-2]) && isdigit(itemPhrase.end()[-1])) { const TimeComp tc = parseTime(Zstr("%Y-%m-%d %H%M%S"), makeStringView(itemPhrase.end() - TIME_STAMP_LENGTH, 17)); //returns TimeComp() on error if (const auto [localTime, timeValid] = localToTimeT(tc); timeValid) { itemPhrase.remove_suffix(TIME_STAMP_LENGTH); if (!itemPhrase.empty()) { assert(itemPhrase.size() >= 2 && endsWith(itemPhrase, Zstr(' '))); itemPhrase = trimCpy(itemPhrase); } logfiles.push_back({AFS::appendRelPath(logFolderPath, fi.itemName), localTime, utfTo(itemPhrase)}); } } } }, nullptr /*onFolder*/, //traverse only one level deep nullptr /*onSymlink*/); return logfiles; } void limitLogfileCount(const AbstractPath& logFolderPath, //throw FileError, X int logfilesMaxAgeDays, //<= 0 := no limit const std::set& logsToKeepPaths, const std::function& notifyStatus /*throw X*/) { if (logfilesMaxAgeDays > 0) { const std::wstring statusPrefix = _("Cleaning up log files:") + L" [" + _P("1 day", "%x days", logfilesMaxAgeDays) + L"] "; if (notifyStatus) notifyStatus(statusPrefix + fmtPath(AFS::getDisplayPath(logFolderPath))); //throw X std::vector logFiles = getLogFiles(logFolderPath); //throw FileError const time_t lastMidnightTime = [] { TimeComp tc = getLocalTime(); //returns TimeComp() on error tc.second = 0; tc.minute = 0; tc.hour = 0; return localToTimeT(tc).first; //0 on error => swallow => no versions trimmed by versionMaxAgeDays }(); const time_t cutOffTime = lastMidnightTime - static_cast(logfilesMaxAgeDays) * 24 * 3600; std::exception_ptr firstError; for (const LogFileInfo& lfi : logFiles) if (lfi.timeStamp < cutOffTime && !logsToKeepPaths.contains(lfi.filePath)) //don't trim latest log files corresponding to last used config files! //nitpicker's corner: what about path differences due to case? e.g. user-overriden log file path changed in case { if (notifyStatus) notifyStatus(statusPrefix + fmtPath(AFS::getDisplayPath(lfi.filePath))); //throw X try { AFS::removeFilePlain(lfi.filePath); //throw FileError } catch (const FileError&) { if (!firstError) firstError = std::current_exception(); }; } if (firstError) //late failure! std::rethrow_exception(firstError); } } } //"Backup FreeFileSync 2013-09-15 015052.123.html" //"Backup FreeFileSync 2013-09-15 015052.123 [Error].html" //"Backup FreeFileSync + RealTimeSync 2013-09-15 015052.123 [Error].log" Zstring fff::generateLogFileName(LogFileFormat logFormat, const ProcessSummary& summary) { //const std::string colon = "\xcb\xb8"; //="modifier letter raised colon" => regular colon is forbidden in file names on Windows and macOS //=> too many issues, most notably cmd.exe is not Unicode-aware: https://freefilesync.org/forum/viewtopic.php?t=1679 Zstring jobNamesFmt; if (!summary.jobNames.empty()) { for (const std::wstring& jobName : summary.jobNames) if (const Zstring jobNameZ = utfTo(jobName); jobNamesFmt.size() + jobNameZ.size() > 200) { jobNamesFmt += Zstr("[...] + "); //avoid hitting file system name length limitations: "lpMaximumComponentLength is commonly 255 characters" break; //https://freefilesync.org/forum/viewtopic.php?t=7113 } else jobNamesFmt += jobNameZ + Zstr(" + "); jobNamesFmt.resize(jobNamesFmt.size() - 3); } const TimeComp tc = getLocalTime(std::chrono::system_clock::to_time_t(summary.startTime)); if (tc == TimeComp()) throw FileError(L"Failed to determine current time: (time_t) " + numberTo(summary.startTime.time_since_epoch().count())); const auto timeMs = std::chrono::duration_cast(summary.startTime.time_since_epoch()).count() % 1000; assert(std::chrono::duration_cast(summary.startTime.time_since_epoch()).count() == std::chrono::system_clock::to_time_t(summary.startTime)); const std::wstring failStatus = [&] { switch (summary.result) { case TaskResult::success: break; case TaskResult::warning: return _("Warning"); case TaskResult::error: return _("Error"); case TaskResult::cancelled: return _("Stopped"); } return std::wstring(); }(); //------------------------------------------------------------------ Zstring logFileName = jobNamesFmt; if (!logFileName.empty()) logFileName += Zstr(' '); logFileName += formatTime(Zstr("%Y-%m-%d %H%M%S"), tc) + Zstr('.') + printNumber(Zstr("%03d"), static_cast(timeMs)); //[ms] should yield a fairly unique name static_assert(TIME_STAMP_LENGTH == 21); if (!failStatus.empty()) logFileName += STATUS_BEGIN_TOKEN + utfTo(failStatus) + STATUS_END_TOKEN; logFileName += logFormat == LogFileFormat::html ? Zstr(".html") : Zstr(".log"); return logFileName; } void fff::saveLogFile(const AbstractPath& logFilePath, //throw FileError, X const ProcessSummary& summary, const ErrorLog& log, int logfilesMaxAgeDays, LogFileFormat logFormat, const std::set& logsToKeepPaths, const std::function& notifyStatus /*throw X*/) { std::exception_ptr firstError; try { saveNewLogFile(logFilePath, logFormat, summary, log, notifyStatus); //throw FileError, X } catch (const FileError&) { if (!firstError) firstError = std::current_exception(); }; try { const std::optional logFolderPath = AFS::getParentPath(logFilePath); assert(logFolderPath); //else: logFilePath == device root; not possible with generateLogFilePath() limitLogfileCount(*logFolderPath, logfilesMaxAgeDays, logsToKeepPaths, notifyStatus); //throw FileError, X } catch (const FileError&) { if (!firstError) firstError = std::current_exception(); }; if (firstError) //late failure! std::rethrow_exception(firstError); } void fff::sendLogAsEmail(const std::string& email, //throw FileError, X const ProcessSummary& summary, const ErrorLog& log, const AbstractPath& logFilePath, const std::function& notifyStatus /*throw X*/) { try { throw SysError(_("Requires FreeFileSync Donation Edition")); } catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot send notification email to %x."), L"%x", L'"' + utfTo(email) + L'"'), e.toString()); } }