File: log_file.cpp

package info (click to toggle)
freefilesync 13.7-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 9,044 kB
  • sloc: cpp: 66,712; ansic: 447; makefile: 216
file content (674 lines) | stat: -rw-r--r-- 29,288 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
// *****************************************************************************
// * 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 <zen/file_io.h>
#include <zen/http.h>
#include <zen/sys_info.h>
//#include "afs/concrete.h"

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<std::string>(TAB_SPACE);

    std::string headerLine;
    for (const std::wstring& jobName : s.jobNames)
        headerLine += (headerLine.empty() ? "" : " + ") + utfTo<std::string>(jobName);

    if (!headerLine.empty())
        headerLine += ' ';

    const TimeComp tc = getLocalTime(std::chrono::system_clock::to_time_t(s.startTime)); //returns TimeComp() on error
    headerLine += utfTo<std::string>(formatTime(formatDateTag, tc) + Zstr(" [") + formatTime(formatTimeTag, tc) + Zstr(']'));

    //assemble summary box
    std::vector<std::string> summary;
    summary.emplace_back();
    summary.push_back(tabSpace + utfTo<std::string>(getSyncResultLabel(s.result)));
    summary.emplace_back();

    const ErrorLogStats logCount = getStats(log);

    if (logCount.errors   > 0) summary.push_back(tabSpace + utfTo<std::string>(_("Errors:")   + L' ' + formatNumber(logCount.errors)));
    if (logCount.warnings > 0) summary.push_back(tabSpace + utfTo<std::string>(_("Warnings:") + L' ' + formatNumber(logCount.warnings)));

    summary.push_back(tabSpace + utfTo<std::string>(_("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<std::string>(_("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<std::chrono::seconds>(s.totalTime).count();
    summary.push_back(tabSpace + utfTo<std::string>(_("Total time:")) + ' ' + utfTo<std::string>(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<std::string>(_("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<std::string>(formatMessage(entry));
            }

        if (logFailTotal > previewCount)
            output += "  [...]  " + utfTo<std::string>(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<std::string>(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<std::string>(getOsDescription() + /*throw FileError*/ +
                                 L" - " + utfTo<std::wstring>(getUserDescription()) /*throw FileError*/ +
                                 (!cm.model .empty() ? L" - " + cm.model  : L"") +
                                 (!cm.vendor.empty() ? L" - " + cm.vendor : L"")) + '\n';
    if (!logFilePath.empty())
        output += utfTo<std::string>(_("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 += "<br>\n";
            ++it;

            //skip duplicate newlines
            for (; it != msg.end() && *it == L'\n'; ++it)
                ;

            //preserve leading spaces
            for (; it != msg.end() && *it == L' '; ++it)
                msgFmt += "&nbsp;";
        }
        else
            msgFmt += *it++;

    return msgFmt;
}

std::string htmlTxt(const      Zstring& str) { return htmlTxt(utfTo<std::string>(str)); }
std::string htmlTxt(const std::wstring& str) { return htmlTxt(utfTo<std::string>(str)); }
std::string htmlTxt(const      wchar_t* str) { return htmlTxt(utfTo<std::string>(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;
    }

    return R"(		<tr>
            <td valign="top">)" + htmlTxt(formatTime(formatTimeTag, getLocalTime(entry.time))) + R"(</td>
            <td valign="top"><img src="https://freefilesync.org/images/log/)" + typeImage + R"(" height="16" alt=")" + typeLabel + R"(:"></td>
            <td>)" + htmlTxt(makeStringView(entry.message.begin(), entry.message.end())) + R"(</td>
        </tr>
)";
}


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<std::wstring>("\xe2\x9c\x94" "\xef\xb8\x8f"); break; //✔️
        case TaskResult::warning: title += utfTo<std::wstring>("\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<std::wstring>("\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"(<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>)" + htmlTxt(generateLogTitle(s)) + R"(</title>
    <style>
        .summary-table td:nth-child(1) { padding-right: 10px; }
        .summary-table td:nth-child(2) { padding-right:  5px; }
        .summary-table img { display: block; }

        .log-items img { display: block; }
        .log-items td { padding-bottom: 0.1em; }
        .log-items td:nth-child(1) { padding-right: 10px; white-space: nowrap; }
        .log-items td:nth-child(2) { padding-right: 10px; }
    </style>
</head>
<body style="font-family: -apple-system, 'Segoe UI', Arial, Tahoma, Helvetica, sans-serif;">
)";

    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"(	<div><span style="font-weight:600; color:gray;">)" + jobNamesFmt + R"(</span> &nbsp;<span style="white-space:nowrap">)" +
              htmlTxt(formatTime(formatDateTag, tc)) + " &nbsp;" + htmlTxt(formatTime(formatTimeTag, tc)) + "</span></div>\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"(
    <div style="margin:10px 0; display:inline-block; border-radius:7px; background:#f8f8f8; box-shadow:1px 1px 4px #888; overflow:hidden;">
        <div style="background-color:white; border-bottom:1px solid #AAA; font-size:larger; padding:10px;">
            <img src="https://freefilesync.org/images/log/)" + resultsStatusImage + R"(" width="32" height="32" alt="" style="vertical-align:middle;">
            <span style="font-weight:600; vertical-align:middle;">)" + htmlTxt(getSyncResultLabel(s.result)) + R"(</span>
        </div>
        <table role="presentation" class="summary-table" style="border-spacing:0; margin-left:10px; padding:5px 10px;">)";

    const ErrorLogStats logCount = getStats(log);

    if (logCount.errors > 0) 
        output += R"(
            <tr>
                <td>)" + htmlTxt(_("Errors:")) + R"(</td>
                <td><img src="https://freefilesync.org/images/log/msg-error.png" width="24" height="24" alt=""></td>
                <td><span style="font-weight:600;">)" + htmlTxt(formatNumber(logCount.errors)) + R"(</span></td>
            </tr>)";

    if (logCount.warnings > 0)
        output += R"(
            <tr>
                <td>)" + htmlTxt(_("Warnings:")) + R"(</td>
                <td><img src="https://freefilesync.org/images/log/msg-warning.png" width="24" height="24" alt=""></td>
                <td><span style="font-weight:600;">)" + htmlTxt(formatNumber(logCount.warnings)) + R"(</span></td>
            </tr>)";

    output += R"(
            <tr>
                <td>)" + htmlTxt(_("Items processed:")) + R"(</td>
                <td><img src="https://freefilesync.org/images/log/file.png" width="24" height="24" alt=""></td>
                <td><span style="font-weight:600;">)" + htmlTxt(formatNumber(s.statsProcessed.items)) + "</span> (" + 
                                          htmlTxt(formatFilesizeShort(s.statsProcessed.bytes)) + R"()</td>
            </tr>)";

    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"(
            <tr>
                <td>)" + htmlTxt(_("Items remaining:")) + R"(</td>
                <td></td>
                <td><span style="font-weight:600;">)" + htmlTxt(formatNumber(s.statsTotal.items - s.statsProcessed.items)) + "</span> (" + 
                                          htmlTxt(formatFilesizeShort(s.statsTotal.bytes - s.statsProcessed.bytes)) + R"()</td>
            </tr>)";

    const int64_t totalTimeSec = std::chrono::duration_cast<std::chrono::seconds>(s.totalTime).count();
    output += R"(
            <tr>
                <td>)" + htmlTxt(_("Total time:")) + R"(</td>
                <td><img src="https://freefilesync.org/images/log/clock.png" width="24" height="24" alt=""></td>
                <td><span style="font-weight: 600;">)" + htmlTxt(formatTimeSpan(totalTimeSec)) + R"(</span></td>
            </tr>
        </table>
    </div>
)";

    //------------ warnings/errors preview ----------------
    const int logFailTotal = logCount.warnings + logCount.errors;
    if (logFailTotal > 0)
    {
        output += R"(
    <div style="font-weight:600; font-size: large;">)" + htmlTxt(_("Errors and warnings:")) + R"(</div>
    <div style="border-bottom: 1px solid #AAA; margin: 5px 0;"></div>
    <table class="log-items" style="line-height:1em; border-spacing:0;">
)";
        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"(	</table>
)";
        if (logFailTotal > previewCount)
            output += R"(	<div><span style="font-weight:600; padding:0 10px;">[&hellip;]</span>)" + 
                      htmlTxt(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logFailTotal), //%x used as plural form placeholder!
                      L"%y", formatNumber(previewCount))) + "</div>\n";

        output += R"(	<div style="border-bottom: 1px solid #AAA; margin: 5px 0;"></div><br>
)";
    }

        output += R"(
    <table class="log-items" style="line-height:1em; border-spacing:0;">
)";
    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"(	</table>
)";

    if (logItemsTotal > logItemsMax)
        output += R"(	<div><span style="font-weight:600; padding:0 10px;">[&hellip;]</span>)" + 
                  htmlTxt(replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", logItemsTotal), //%x used as plural form placeholder!
                          L"%y", formatNumber(logItemsMax))) + "</div>\n";

    output += R"(
    <div style="border-bottom:1px solid #AAA; margin:5px 0;"></div>
    <div style="font-size:smaller;">
        <img src="https://freefilesync.org/images/log/)" + osImage + R"(" width="24" height="24" alt="" style="vertical-align:middle;">
        <span style="vertical-align:middle;">)" + htmlTxt(getOsDescription()) + /*throw FileError*/ + 
            " &ndash; " + htmlTxt(getUserDescription()) /*throw FileError*/ + 
            (!cm.model .empty() ? " &ndash; " + htmlTxt(cm.model ) : "") +
            (!cm.vendor.empty() ? " &ndash; " + htmlTxt(cm.vendor) : "") + R"(</span>
    </div>)";

    if (!logFilePath.empty())
        output += R"(
    <div style="font-size:smaller;">
        <img src="https://freefilesync.org/images/log/log.png" width="24" height="24" alt=")" + htmlTxt(_("Log file:")) + R"(" style="vertical-align:middle;">
        <span style="font-family: Consolas,'Courier New',Courier,monospace; vertical-align:middle;">)" + htmlTxt(logFilePath) + R"(</span>
    </div>)";

    output += R"(
</body>
</html>
)";
    return output;
}

//-> Astyle fucks up! => no INDENT-ON


//write log items in blocks instead of creating one big string: memory allocation might fail; think 1 million entries!
template <class Function> 
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<int>(log.size()), logItemsMax): //throw FileError
                   generateLogFooterTxt (logFilePath, static_cast<int>(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<void(std::wstring&& msg)>& notifyStatus /*throw X*/)
{
    //create logfile folder if required
    if (const std::optional<AbstractPath> 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<AFS::OutputStream> 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<int>::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<LogFileInfo> getLogFiles(const AbstractPath& logFolderPath) //throw FileError
{
    std::vector<LogFileInfo> 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<ZstringView>(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<std::wstring>(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<AbstractPath>& logsToKeepPaths,
                       const std::function<void(std::wstring&& msg)>& 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<LogFileInfo> 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<time_t>(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<Zstring>(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<std::wstring>(summary.startTime.time_since_epoch().count()));

    const auto timeMs = std::chrono::duration_cast<std::chrono::milliseconds>(summary.startTime.time_since_epoch()).count() % 1000;
    assert(std::chrono::duration_cast<std::chrono::seconds>(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<Zstring>(Zstr("%03d"), static_cast<int>(timeMs)); //[ms] should yield a fairly unique name
    static_assert(TIME_STAMP_LENGTH == 21);

    if (!failStatus.empty())
        logFileName += STATUS_BEGIN_TOKEN + utfTo<Zstring>(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<AbstractPath>& logsToKeepPaths,
                      const std::function<void(std::wstring&& msg)>& 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<AbstractPath> 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<void(std::wstring&& msg)>& 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<std::wstring>(email) + L'"'), e.toString()); }
}